diff --git a/.gitignore b/.gitignore index 004f95c18..b2221fde6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,9 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts *.db -*.sqlite +*.sqlite* !Dockerfile.sqlite -*.sqlite3 +*.sqlite3* *.log .machinelogs*.json *-audit.json @@ -54,3 +54,4 @@ hydrateSaas.ts CLAUDE.md drizzle.config.ts server/setup/migrations.ts +solo.yml \ No newline at end of file diff --git a/messages/en-US.json b/messages/en-US.json index 306ea7d39..3999c9c69 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -208,11 +208,33 @@ "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", + "resourcePoliciesTitle": "Manage Resource Policies", + "resourcePoliciesAttachedResourcesColumnTitle": "Attached resources", + "resourcePoliciesAttachedResources": "{count} resource(s)", + "resourcePoliciesAttachedResourcesEmpty": "no resources", + "resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources", + "resourcePoliciesSearch": "Search policies...", + "resourcePoliciesAdd": "Add Policy", + "resourcePoliciesDefaultBadgeText": "Default policy", + "resourcePoliciesCreate": "Create Resource Policy", + "resourcePoliciesCreateDescription": "Follow the steps below to create a new policy", + "resourcePolicyName": "Policy Name", + "resourcePolicyNameDescription": "Give this policy a name to identify it across your resources", + "resourcePolicyNamePlaceholder": "e.g. Internal Access Policy", + "resourcePoliciesSeeAll": "See All Policies", + "resourcePolicyAuthMethodAdd": "Add Authentication Method", + "resourcePolicyOtpEmailAdd": "Add OTP emails", + "resourcePolicyRulesAdd": "Add Rules", + "resourcePolicyAuthMethodsDescription": "Allow access to resources via additional auth methods", + "resourcePolicyUsersRolesDescription": "Configure which users and roles can visit associated resources", + "rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy", "authentication": "Authentication", "protected": "Protected", "notProtected": "Not Protected", "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?", + "resourcePolicyMessageRemove": "Once removed, the resource policy will no longer be accessible. All resources associated with the resource will be unlinked and left without authentication.", + "resourcePolicyQuestionRemove": "Are you sure you want to remove the resource policy from the organization?", "resourceHTTP": "HTTPS Resource", "resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.", "resourceRaw": "Raw TCP/UDP Resource", @@ -257,6 +279,8 @@ "resourceLearnRaw": "Learn how to configure TCP/UDP resources", "resourceBack": "Back to Resources", "resourceGoTo": "Go to Resource", + "resourcePolicyDelete": "Delete Resource Policy", + "resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy", "resourceDelete": "Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource", "labelDelete": "Delete Label", @@ -286,6 +310,8 @@ "rules": "Rules", "resourceSettingDescription": "Configure the settings on the resource", "resourceSetting": "{resourceName} Settings", + "resourcePolicySettingDescription": "Configure the settings on the resource policy", + "resourcePolicySetting": "{policyName} Settings", "alwaysAllow": "Bypass Auth", "alwaysDeny": "Block Access", "passToAuth": "Pass to Auth", @@ -768,6 +794,16 @@ "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", + "policyErrorUpdate": "Error updating policy", + "policyErrorUpdateDescription": "An error occurred when updating the policy", + "policyErrorUpdateMessageDescription": "An unexpected error occurred", + "policyCreatedSuccess": "Resource policy succesfully created", + "policyUpdatedSuccess": "Resource policy succesfully updated", + "authMethodsSave": "Save auth methods", + "rulesSave": "Save Rules", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", "resourceErrorCreateMessage": "Error creating resource:", @@ -831,6 +867,16 @@ "pincodeAdd": "Add PIN Code", "pincodeRemove": "Remove PIN Code", "resourceAuthMethods": "Authentication Methods", + "resourcePolicyAuthMethodsEmpty": "No authentication method", + "resourcePolicyOtpEmpty": "No one time password", + "resourcePolicyReadOnly": "This policy is Read only", + "resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it on this page.", + "resourcePolicyTypeSave": "Save Resource type", + "resourcePolicySelect": "Select resource policy", + "resourcePolicySelectError": "Select a resource policy", + "resourcePolicyNotFound": "Policy not found", + "resourcePolicySearch": "Search policies", + "resourcePolicyRulesEmpty": "No authentication rules", "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", "resourceAuthSettingsSave": "Saved successfully", "resourceAuthSettingsSaveDescription": "Authentication settings have been saved", @@ -866,6 +912,12 @@ "resourcePincodeSetupTitle": "Set Pincode", "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", "resourceRoleDescription": "Admins can always access this resource.", + "resourcePolicySelectTitle": "Resource Access Policy", + "resourcePolicySelectDescription": "Select the resource policy type for authentication", + "resourcePolicyInline": "Inline Resource Policy", + "resourcePolicyInlineDescription": "Access Policy scoped to only this resource", + "resourcePolicyShared": "Shared Resource Policy", + "resourcePolicySharedDescription": "Access Policy shared accross multiple resources", "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Access Controls", @@ -1407,6 +1459,8 @@ "sidebarResources": "Resources", "sidebarProxyResources": "Public", "sidebarClientResources": "Private", + "sidebarPolicies": "Policies", + "sidebarResourcePolicies": "Resources", "sidebarAccessControl": "Access Control", "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarTeam": "Team", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 886114998..71fb33156 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import logger from "@server/logger"; export enum ActionsEnum { createOrgUser = "createOrgUser", @@ -163,7 +164,21 @@ export enum ActionsEnum { updateBrowserGatewayTarget = "updateBrowserGatewayTarget", deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget", getBrowserGatewayTarget = "getBrowserGatewayTarget", - listBrowserGatewayTargets = "listBrowserGatewayTargets" + listBrowserGatewayTargets = "listBrowserGatewayTargets", + listResourcePolicies = "listResourcePolicies", + getResourcePolicy = "getResourcePolicy", + createResourcePolicy = "createResourcePolicy", + updateResourcePolicy = "updateResourcePolicy", + deleteResourcePolicy = "deleteResourcePolicy", + listResourcePolicyRoles = "listResourcePolicyRoles", + setResourcePolicyRoles = "setResourcePolicyRoles", + listResourcePolicyUsers = "listResourcePolicyUsers", + setResourcePolicyUsers = "setResourcePolicyUsers", + setResourcePolicyPassword = "setResourcePolicyPassword", + setResourcePolicyPincode = "setResourcePolicyPincode", + setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", + setResourcePolicyWhitelist = "setResourcePolicyWhitelist", + setResourcePolicyRules = "setResourcePolicyRules" } export async function checkUserActionPermission( @@ -196,6 +211,23 @@ export async function checkUserActionPermission( } } + // If no direct permission, check role-based permission (any of user's roles) + const roleActionPermission = await db + .select() + .from(roleActions) + .where( + and( + eq(roleActions.actionId, actionId), + inArray(roleActions.roleId, userOrgRoleIds), + eq(roleActions.orgId, req.userOrgId!) + ) + ) + .limit(1); + + if (roleActionPermission.length > 0) { + return true; + } + // Check if the user has direct permission for the action in the current org const userActionPermission = await db .select() @@ -213,20 +245,7 @@ export async function checkUserActionPermission( return true; } - // If no direct permission, check role-based permission (any of user's roles) - const roleActionPermission = await db - .select() - .from(roleActions) - .where( - and( - eq(roleActions.actionId, actionId), - inArray(roleActions.roleId, userOrgRoleIds), - eq(roleActions.orgId, req.userOrgId!) - ) - ) - .limit(1); - - return roleActionPermission.length > 0; + return false; } catch (error) { console.error("Error checking user action permission:", error); throw createHttpError( 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/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 273bb6458..1033b7538 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -119,6 +119,16 @@ export const sites = pgTable("sites", { export const resources = pgTable("resources", { resourceId: serial("resourceId").primaryKey(), + resourcePolicyId: integer("resourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { onDelete: "set null" } + ), + defaultResourcePolicyId: integer("defaultResourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { + onDelete: "restrict" + } + ), resourceGuid: varchar("resourceGuid", { length: 36 }) .unique() .notNull() @@ -623,6 +633,38 @@ export const userResources = pgTable("userResources", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); +export const rolePolicies = pgTable("rolePolicies", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const userPolicies = pgTable("userPolicies", { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyWhiteList = pgTable("resourcePolicyWhitelist", { + whitelistId: serial("id").primaryKey(), + email: varchar("email").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + export const userInvites = pgTable("userInvites", { inviteId: varchar("inviteId").primaryKey(), orgId: varchar("orgId") @@ -688,6 +730,40 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable( } ); +export const resourcePolicyPincode = pgTable("resourcePolicyPincode", { + pincodeId: serial("pincodeId").primaryKey(), + pincodeHash: varchar("pincodeHash").notNull(), + digitLength: integer("digitLength").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyPassword = pgTable("resourcePolicyPassword", { + passwordId: serial("passwordId").primaryKey(), + passwordHash: varchar("passwordHash").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyHeaderAuth = pgTable("resourcePolicyHeaderAuth", { + headerAuthId: serial("headerAuthId").primaryKey(), + headerAuthHash: varchar("headerAuthHash").notNull(), + extendedCompatibility: boolean("extendedCompatibility") + .notNull() + .default(true), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + export const resourceAccessToken = pgTable("resourceAccessToken", { accessTokenId: varchar("accessTokenId").primaryKey(), orgId: varchar("orgId") @@ -781,6 +857,43 @@ export const resourceRules = pgTable("resourceRules", { value: varchar("value").notNull() }); +export const resourcePolicyRules = pgTable("resourcePolicyRules", { + ruleId: serial("ruleId").primaryKey(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }), + enabled: boolean("enabled").notNull().default(true), + priority: integer("priority").notNull(), + action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), + match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(), + value: varchar("value").notNull() +}); + +export const resourcePolicies = pgTable("resourcePolicies", { + resourcePolicyId: serial("resourcePolicyId").primaryKey(), + sso: boolean("sso").notNull().default(true), + applyRules: boolean("applyRules").notNull().default(false), + scope: varchar("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + emailWhitelistEnabled: boolean("emailWhitelistEnabled") + .notNull() + .default(false), + idpId: integer("idpId").references(() => idp.idpId, { + onDelete: "set null" + }), + niceId: text("niceId").notNull(), + name: varchar("name").notNull(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() +}); + export const supporterKey = pgTable("supporterKey", { keyId: serial("keyId").primaryKey(), key: varchar("key").notNull(), @@ -1293,3 +1406,6 @@ export type RoundTripMessageTracker = InferSelectModel< export type Network = InferSelectModel; export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; +export type ResourcePolicy = InferSelectModel; +export type RolePolicy = InferSelectModel; +export type UserPolicy = InferSelectModel; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 46b45b1a0..17844e13c 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -17,10 +17,13 @@ import { resourceHeaderAuth, ResourceHeaderAuth, resourceRules, + resourcePolicyRules, resources, roleResources, + rolePolicies, sessions, userResources, + userPolicies, users, ResourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility @@ -154,58 +157,126 @@ export async function getRoleName(roleId: number): Promise { } /** - * Check if role has access to resource + * Check if role has access to resource (direct or via resource policy) */ export async function getRoleResourceAccess( resourceId: number, roleIds: number[] ) { - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - inArray(roleResources.roleId, roleIds) + const [direct, viaPolicies] = await Promise.all([ + db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ), + db + .select({ + roleId: rolePolicies.roleId, + resourcePolicyId: rolePolicies.resourcePolicyId + }) + .from(rolePolicies) + .innerJoin( + resources, + eq(resources.resourcePolicyId, rolePolicies.resourcePolicyId) ) - ); + .where( + and( + eq(resources.resourceId, resourceId), + inArray(rolePolicies.roleId, roleIds) + ) + ) + ]); - return roleResourceAccess.length > 0 ? roleResourceAccess : null; + const combined = [...direct, ...viaPolicies]; + return combined.length > 0 ? combined : null; } /** - * Check if user has direct access to resource + * Check if user has access to resource (direct or via resource policy) */ export async function getUserResourceAccess( userId: string, resourceId: number ) { - const userResourceAccess = await db - .select() - .from(userResources) - .where( - and( - eq(userResources.userId, userId), - eq(userResources.resourceId, resourceId) + const [direct, viaPolicies] = await Promise.all([ + db + .select() + .from(userResources) + .where( + and( + eq(userResources.userId, userId), + eq(userResources.resourceId, resourceId) + ) ) - ) - .limit(1); + .limit(1), + db + .select({ + userId: userPolicies.userId, + resourcePolicyId: userPolicies.resourcePolicyId + }) + .from(userPolicies) + .innerJoin( + resources, + eq(resources.resourcePolicyId, userPolicies.resourcePolicyId) + ) + .where( + and( + eq(resources.resourceId, resourceId), + eq(userPolicies.userId, userId) + ) + ) + .limit(1) + ]); - return userResourceAccess.length > 0 ? userResourceAccess[0] : null; + return direct[0] ?? viaPolicies[0] ?? null; } /** - * Get resource rules for a given resource + * Get resource rules for a given resource (direct and via resource policy) */ export async function getResourceRules( resourceId: number ): Promise { - const rules = await db - .select() - .from(resourceRules) - .where(eq(resourceRules.resourceId, resourceId)); + const [directRules, policyRules] = await Promise.all([ + db + .select() + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)), + db + .select({ + ruleId: resourcePolicyRules.ruleId, + resourceId: sql`${resourceId}`, + enabled: resourcePolicyRules.enabled, + priority: resourcePolicyRules.priority, + action: resourcePolicyRules.action, + match: resourcePolicyRules.match, + value: resourcePolicyRules.value + }) + .from(resourcePolicyRules) + .innerJoin( + resources, + eq( + resources.resourcePolicyId, + resourcePolicyRules.resourcePolicyId + ) + ) + .where(eq(resources.resourceId, resourceId)) + ]); - return rules; + const maxDirectPriority = directRules.reduce( + (max, r) => Math.max(max, r.priority), + 0 + ); + const offsetPolicyRules = policyRules.map((r) => ({ + ...r, + priority: maxDirectPriority + r.priority + })); + + return [...directRules, ...offsetPolicyRules] as ResourceRule[]; } /** diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c36e2ea09..2376927d2 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -135,6 +135,16 @@ export const sites = sqliteTable("sites", { export const resources = sqliteTable("resources", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), + resourcePolicyId: integer("resourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { onDelete: "set null" } + ), + defaultResourcePolicyId: integer("defaultResourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { + onDelete: "restrict" + } + ), resourceGuid: text("resourceGuid", { length: 36 }) .unique() .notNull() @@ -1022,6 +1032,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", { headerAuthHash: text("headerAuthHash").notNull() }); +export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", { + pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }), + pincodeHash: text("pincodeHash").notNull(), + digitLength: integer("digitLength").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", { + passwordId: integer("passwordId").primaryKey({ autoIncrement: true }), + passwordHash: text("passwordHash").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyHeaderAuth = sqliteTable( + "resourcePolicyHeaderAuth", + { + headerAuthId: integer("headerAuthId").primaryKey({ + autoIncrement: true + }), + headerAuthHash: text("headerAuthHash").notNull(), + extendedCompatibility: integer("extendedCompatibility", { + mode: "boolean" + }) + .notNull() + .default(true), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) + } +); + export const resourceHeaderAuthExtendedCompatibility = sqliteTable( "resourceHeaderAuthExtendedCompatibility", { @@ -1136,6 +1187,77 @@ export const resourceRules = sqliteTable("resourceRules", { value: text("value").notNull() }); +export const rolePolicies = sqliteTable("rolePolicies", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const userPolicies = sqliteTable("userPolicies", { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", { + whitelistId: integer("id").primaryKey({ autoIncrement: true }), + email: text("email").notNull(), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }) +}); + +export const resourcePolicyRules = sqliteTable("resourcePolicyRules", { + ruleId: integer("ruleId").primaryKey({ autoIncrement: true }), + resourcePolicyId: integer("resourcePolicyId") + .notNull() + .references(() => resourcePolicies.resourcePolicyId, { + onDelete: "cascade" + }), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + priority: integer("priority").notNull(), + action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), + match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(), + value: text("value").notNull() +}); + +export const resourcePolicies = sqliteTable("resourcePolicies", { + resourcePolicyId: integer("resourcePolicyId").primaryKey(), + sso: integer("sso", { mode: "boolean" }).notNull().default(true), + applyRules: integer("applyRules", { mode: "boolean" }) + .notNull() + .default(false), + scope: text("scope") + .$type<"global" | "resource">() + .notNull() + .default("global"), + emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) + .notNull() + .default(false), + niceId: text("niceId").notNull(), + idpId: integer("idpId").references(() => idp.idpId, { + onDelete: "set null" + }), + name: text("name").notNull(), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() +}); + export const supporterKey = sqliteTable("supporterKey", { keyId: integer("keyId").primaryKey({ autoIncrement: true }), key: text("key").notNull(), @@ -1403,3 +1525,6 @@ export type RoundTripMessageTracker = InferSelectModel< >; export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; +export type ResourcePolicy = InferSelectModel; +export type RolePolicy = InferSelectModel; +export type UserPolicy = InferSelectModel; diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 307465804..2065398f2 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -26,7 +26,8 @@ export enum TierFeature { AlertingRules = "alertingRules", WildcardSubdomain = "wildcardSubdomain", Labels = "labels", - NewtAutoUpdate = "newtAutoUpdate" + NewtAutoUpdate = "newtAutoUpdate", + ResourcePolicies = "resourcePolicies" } export const tierMatrix: Record = { @@ -70,5 +71,6 @@ export const tierMatrix: Record = { [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], [TierFeature.AlertingRules]: ["tier3", "enterprise"], [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], - [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"] + [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.ResourcePolicies]: ["tier3", "enterprise"] }; diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index a905a2c7b..2f22298fa 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -16,7 +16,6 @@ import logger from "@server/logger"; import { sites } from "@server/db"; import { eq, and, isNotNull } from "drizzle-orm"; import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; -import { addTargets as addClientTargets } from "@server/routers/client/targets"; import { ClientResourcesResults, updateClientResources diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 3fb8711c8..59d3fbf59 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -16,7 +16,15 @@ import { Transaction, userOrgs, userResources, - users + users, + resourcePolicies, + resourcePolicyPassword, + resourcePolicyPincode, + resourcePolicyHeaderAuth, + resourcePolicyRules, + resourcePolicyWhiteList, + rolePolicies, + userPolicies } from "@server/db"; import { resources, targets, sites } from "@server/db"; import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; @@ -30,6 +38,7 @@ import logger from "@server/logger"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { pickPort } from "@server/routers/target/helpers"; import { resourcePassword } from "@server/db"; +import { getUniqueResourcePolicyName } from "@server/db/names"; import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidRegionId } from "@server/db/regions"; @@ -242,176 +251,314 @@ export async function updateProxyResources( resourceData.maintenance = undefined; } - [resource] = await trx - .update(resources) - .set({ - name: resourceData.name || "Unnamed Resource", - mode: resourceData.mode, - proxyPort: ["http", "ssh", "rdp", "vnc"].includes( - resourceData.mode || "" - ) - ? null - : resourceData["proxy-port"], - fullDomain: ["http", "ssh", "rdp", "vnc"].includes( - resourceData.mode || "" - ) - ? resourceData["full-domain"] - : null, - subdomain: domain ? domain.subdomain : null, - domainId: domain ? domain.domainId : null, - wildcard: domain ? domain.wildcard : false, - enabled: resourceEnabled, - sso: resourceData.auth?.["sso-enabled"] || false, - skipToIdpId: - resourceData.auth?.["auto-login-idp"] || null, - ssl: resourceSsl, - setHostHeader: resourceData["host-header"] || null, - tlsServerName: resourceData["tls-server-name"] || null, - emailWhitelistEnabled: resourceData.auth?.[ - "whitelist-users" - ] - ? resourceData.auth["whitelist-users"].length > 0 - : false, - headers: headers || null, - applyRules: - resourceData.rules && resourceData.rules.length > 0, - pamMode: - resourceData["auth-daemon"]?.pam || "passthrough", - authDaemonMode: - resourceData["auth-daemon"]?.mode || "native", - authDaemonPort: - resourceData["auth-daemon"]?.port || 22123, - maintenanceModeEnabled: - resourceData.maintenance?.enabled, - maintenanceModeType: resourceData.maintenance?.type, - maintenanceTitle: resourceData.maintenance?.title, - maintenanceMessage: resourceData.maintenance?.message, - maintenanceEstimatedTime: - resourceData.maintenance?.["estimated-time"] - }) - .where( - eq(resources.resourceId, existingResource.resourceId) - ) - .returning(); + // Look up the admin role (needed for inline policy creation) + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); - await trx - .delete(resourcePassword) - .where( - eq( - resourcePassword.resourceId, - existingResource.resourceId - ) - ); - if (resourceData.auth?.password) { - const passwordHash = await hashPassword( - resourceData.auth.password - ); - - await trx.insert(resourcePassword).values({ - resourceId: existingResource.resourceId, - passwordHash - }); + if (!adminRole) { + throw new Error(`Admin role not found`); } - await trx - .delete(resourcePincode) - .where( - eq( - resourcePincode.resourceId, - existingResource.resourceId + if (resourceData.policy) { + // SHARED POLICY MODE: look up shared policy by niceId + const [sharedPolicy] = await trx + .select() + .from(resourcePolicies) + .where( + and( + eq( + resourcePolicies.niceId, + resourceData.policy + ), + eq(resourcePolicies.orgId, orgId) + ) ) - ); - if (resourceData.auth?.pincode) { - const pincodeHash = await hashPassword( - resourceData.auth.pincode.toString() - ); + .limit(1); - await trx.insert(resourcePincode).values({ - resourceId: existingResource.resourceId, - pincodeHash, - digitLength: 6 - }); - } - - await trx - .delete(resourceHeaderAuth) - .where( - eq( - resourceHeaderAuth.resourceId, - existingResource.resourceId - ) - ); - - await trx - .delete(resourceHeaderAuthExtendedCompatibility) - .where( - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - existingResource.resourceId - ) - ); - - if (resourceData.auth?.["basic-auth"]) { - const headerAuthUser = - resourceData.auth?.["basic-auth"]?.user; - const headerAuthPassword = - resourceData.auth?.["basic-auth"]?.password; - const headerAuthExtendedCompatibility = - resourceData.auth?.["basic-auth"] - ?.extendedCompatibility; - if ( - headerAuthUser && - headerAuthPassword && - headerAuthExtendedCompatibility !== null - ) { - const headerAuthHash = await hashPassword( - Buffer.from( - `${headerAuthUser}:${headerAuthPassword}` - ).toString("base64") + if (!sharedPolicy) { + throw new Error( + `Shared policy not found: ${resourceData.policy} in org ${orgId}` ); - await Promise.all([ - trx.insert(resourceHeaderAuth).values({ - resourceId: existingResource.resourceId, - headerAuthHash - }), - trx - .insert(resourceHeaderAuthExtendedCompatibility) - .values({ - resourceId: existingResource.resourceId, - extendedCompatibilityIsActivated: - headerAuthExtendedCompatibility - }) - ]); } - } - if (resourceData.auth?.["sso-roles"]) { - const ssoRoles = resourceData.auth?.["sso-roles"]; - await syncRoleResources( - existingResource.resourceId, - ssoRoles, + [resource] = await trx + .update(resources) + .set({ + name: resourceData.name || "Unnamed Resource", + + mode: resourceData.mode, + proxyPort: ["http", "ssh", "rdp", "vnc"].includes( + resourceData.mode || "" + ) + ? null + : resourceData["proxy-port"], + fullDomain: ["http", "ssh", "rdp", "vnc"].includes( + resourceData.mode || "" + ) + ? resourceData["full-domain"] + : null, + subdomain: domain ? domain.subdomain : null, + domainId: domain ? domain.domainId : null, + wildcard: domain ? domain.wildcard : false, + enabled: resourceEnabled, + sso: resourceData.auth?.["sso-enabled"] || false, + skipToIdpId: + resourceData.auth?.["auto-login-idp"] || null, + ssl: resourceSsl, + setHostHeader: resourceData["host-header"] || null, + tlsServerName: + resourceData["tls-server-name"] || null, + emailWhitelistEnabled: resourceData.auth?.[ + "whitelist-users" + ] + ? resourceData.auth["whitelist-users"].length > + 0 + : false, + headers: headers || null, + applyRules: + resourceData.rules && + resourceData.rules.length > 0, + pamMode: + resourceData["auth-daemon"]?.pam || + "passthrough", + authDaemonMode: + resourceData["auth-daemon"]?.mode || "native", + authDaemonPort: + resourceData["auth-daemon"]?.port || 22123, + maintenanceModeEnabled: + resourceData.maintenance?.enabled, + maintenanceModeType: resourceData.maintenance?.type, + maintenanceTitle: resourceData.maintenance?.title, + maintenanceMessage: + resourceData.maintenance?.message, + maintenanceEstimatedTime: + resourceData.maintenance?.["estimated-time"], + resourcePolicyId: sharedPolicy.resourcePolicyId + }) + .where( + eq( + resources.resourceId, + existingResource.resourceId + ) + ) + .returning(); + + // Update OLD resource-level auth tables + await trx + .delete(resourcePassword) + .where( + eq( + resourcePassword.resourceId, + existingResource.resourceId + ) + ); + if (resourceData.auth?.password) { + const passwordHash = await hashPassword( + resourceData.auth.password + ); + await trx.insert(resourcePassword).values({ + resourceId: existingResource.resourceId, + passwordHash + }); + } + + await trx + .delete(resourcePincode) + .where( + eq( + resourcePincode.resourceId, + existingResource.resourceId + ) + ); + if (resourceData.auth?.pincode) { + const pincodeHash = await hashPassword( + resourceData.auth.pincode.toString() + ); + await trx.insert(resourcePincode).values({ + resourceId: existingResource.resourceId, + pincodeHash, + digitLength: 6 + }); + } + + await trx + .delete(resourceHeaderAuth) + .where( + eq( + resourceHeaderAuth.resourceId, + existingResource.resourceId + ) + ); + await trx + .delete(resourceHeaderAuthExtendedCompatibility) + .where( + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + existingResource.resourceId + ) + ); + if (resourceData.auth?.["basic-auth"]) { + const headerAuthUser = + resourceData.auth["basic-auth"]?.user; + const headerAuthPassword = + resourceData.auth["basic-auth"]?.password; + const headerAuthExtendedCompatibility = + resourceData.auth["basic-auth"] + ?.extendedCompatibility; + if ( + headerAuthUser && + headerAuthPassword && + headerAuthExtendedCompatibility !== null + ) { + const headerAuthHash = await hashPassword( + Buffer.from( + `${headerAuthUser}:${headerAuthPassword}` + ).toString("base64") + ); + await Promise.all([ + trx.insert(resourceHeaderAuth).values({ + resourceId: existingResource.resourceId, + headerAuthHash + }), + trx + .insert( + resourceHeaderAuthExtendedCompatibility + ) + .values({ + resourceId: existingResource.resourceId, + extendedCompatibilityIsActivated: + headerAuthExtendedCompatibility + }) + ]); + } + } + + if (resourceData.auth?.["sso-roles"]) { + await syncRoleResources( + existingResource.resourceId, + resourceData.auth["sso-roles"], + orgId, + trx + ); + } + + if (resourceData.auth?.["sso-users"]) { + await syncUserResources( + existingResource.resourceId, + resourceData.auth["sso-users"], + orgId, + trx + ); + } + + if (resourceData.auth?.["whitelist-users"]) { + await syncWhitelistUsers( + existingResource.resourceId, + resourceData.auth["whitelist-users"], + orgId, + trx + ); + } + } else { + // INLINE POLICY MODE: ensure inline policy exists + const inlinePolicyId = await ensureInlinePolicy( + existingResource.defaultResourcePolicyId, orgId, + resourceNiceId, + adminRole.roleId, trx ); - } - if (resourceData.auth?.["sso-users"]) { - const ssoUsers = resourceData.auth?.["sso-users"]; - await syncUserResources( - existingResource.resourceId, - ssoUsers, - orgId, + [resource] = await trx + .update(resources) + .set({ + name: resourceData.name || "Unnamed Resource", + protocol: protocol || "tcp", + http: http, + proxyPort: http ? null : resourceData["proxy-port"], + fullDomain: http + ? resourceData["full-domain"] + : null, + subdomain: domain ? domain.subdomain : null, + domainId: domain ? domain.domainId : null, + wildcard: domain ? domain.wildcard : false, + enabled: resourceEnabled, + ssl: resourceSsl, + setHostHeader: resourceData["host-header"] || null, + tlsServerName: + resourceData["tls-server-name"] || null, + headers: headers || null, + maintenanceModeEnabled: + resourceData.maintenance?.enabled, + maintenanceModeType: resourceData.maintenance?.type, + maintenanceTitle: resourceData.maintenance?.title, + maintenanceMessage: + resourceData.maintenance?.message, + maintenanceEstimatedTime: + resourceData.maintenance?.["estimated-time"], + resourcePolicyId: null, + defaultResourcePolicyId: inlinePolicyId + }) + .where( + eq( + resources.resourceId, + existingResource.resourceId + ) + ) + .returning(); + + // Clear the old resource-level auth tables (not used in inline policy mode) + await Promise.all([ trx - ); - } + .delete(resourcePassword) + .where( + eq( + resourcePassword.resourceId, + existingResource.resourceId + ) + ), + trx + .delete(resourcePincode) + .where( + eq( + resourcePincode.resourceId, + existingResource.resourceId + ) + ), + trx + .delete(resourceHeaderAuth) + .where( + eq( + resourceHeaderAuth.resourceId, + existingResource.resourceId + ) + ), + trx + .delete(resourceHeaderAuthExtendedCompatibility) + .where( + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + existingResource.resourceId + ) + ), + trx + .delete(resourceWhitelist) + .where( + eq( + resourceWhitelist.resourceId, + existingResource.resourceId + ) + ) + ]); - if (resourceData.auth?.["whitelist-users"]) { - const whitelistUsers = - resourceData.auth?.["whitelist-users"]; - await syncWhitelistUsers( - existingResource.resourceId, - whitelistUsers, + // Update inline policy auth fields and policy-level tables + await syncInlinePolicyAuth( + inlinePolicyId, orgId, + resourceData, trx ); } @@ -634,69 +781,99 @@ export async function updateProxyResources( } } - const existingRules = await trx - .select() - .from(resourceRules) - .where( - eq(resourceRules.resourceId, existingResource.resourceId) - ) - .orderBy(resourceRules.priority); + if (resourceData.policy) { + // SHARED POLICY MODE: sync rules into old resourceRules table + const existingRules = await trx + .select() + .from(resourceRules) + .where( + eq( + resourceRules.resourceId, + existingResource.resourceId + ) + ) + .orderBy(resourceRules.priority); - // Sync rules - for (const [index, rule] of resourceData.rules?.entries() || []) { - const intendedPriority = rule.priority ?? index + 1; - const existingRule = existingRules[index]; - if (existingRule) { - if ( - existingRule.action !== getRuleAction(rule.action) || - existingRule.match !== rule.match.toUpperCase() || - existingRule.value !== - getRuleValue( - rule.match.toUpperCase(), - rule.value - ) || - existingRule.priority !== intendedPriority - ) { - validateRule(rule); - await trx - .update(resourceRules) - .set({ - action: getRuleAction(rule.action), - match: rule.match.toUpperCase(), - value: getRuleValue( + // Sync rules + for (const [index, rule] of resourceData.rules?.entries() || + []) { + const intendedPriority = rule.priority ?? index + 1; + const existingRule = existingRules[index]; + if (existingRule) { + if ( + existingRule.action !== + getRuleAction(rule.action) || + existingRule.match !== rule.match.toUpperCase() || + existingRule.value !== + getRuleValue( rule.match.toUpperCase(), rule.value - ), - priority: intendedPriority - }) - .where( - eq(resourceRules.ruleId, existingRule.ruleId) - ); + ) || + existingRule.priority !== intendedPriority + ) { + validateRule(rule); + await trx + .update(resourceRules) + .set({ + action: getRuleAction(rule.action), + match: rule.match.toUpperCase(), + value: getRuleValue( + rule.match.toUpperCase(), + rule.value + ), + priority: intendedPriority + }) + .where( + eq( + resourceRules.ruleId, + existingRule.ruleId + ) + ); + } + } else { + validateRule(rule); + await trx.insert(resourceRules).values({ + resourceId: existingResource.resourceId, + action: getRuleAction(rule.action), + match: rule.match.toUpperCase(), + value: getRuleValue( + rule.match.toUpperCase(), + rule.value + ), + priority: intendedPriority + }); } - } else { - validateRule(rule); - await trx.insert(resourceRules).values({ - resourceId: existingResource.resourceId, - action: getRuleAction(rule.action), - match: rule.match.toUpperCase(), - value: getRuleValue( - rule.match.toUpperCase(), - rule.value - ), - priority: intendedPriority - }); } - } - if (existingRules.length > (resourceData.rules?.length || 0)) { - const rulesToDelete = existingRules.slice( - resourceData.rules?.length || 0 - ); - for (const rule of rulesToDelete) { - await trx - .delete(resourceRules) - .where(eq(resourceRules.ruleId, rule.ruleId)); + if (existingRules.length > (resourceData.rules?.length || 0)) { + const rulesToDelete = existingRules.slice( + resourceData.rules?.length || 0 + ); + for (const rule of rulesToDelete) { + await trx + .delete(resourceRules) + .where(eq(resourceRules.ruleId, rule.ruleId)); + } } + } else { + // INLINE POLICY MODE: sync rules into policy-level table + const inlinePolicyId = resource!.defaultResourcePolicyId!; + + // Clear the old resource-level rules table + await trx + .delete(resourceRules) + .where( + eq( + resourceRules.resourceId, + existingResource.resourceId + ) + ); + + await syncInlinePolicyRules( + inlinePolicyId, + resourceData.rules || [], + trx + ); } logger.debug(`Updated resource ${existingResource.resourceId}`); @@ -722,6 +899,58 @@ export async function updateProxyResources( resourceData.maintenance = undefined; } + // Look up admin role (needed for inline policy and roleResources) + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + throw new Error(`Admin role not found`); + } + + // Always create an inline policy for the resource + const policyNiceId = await getUniqueResourcePolicyName(orgId); + const [inlinePolicy] = await trx + .insert(resourcePolicies) + .values({ + niceId: policyNiceId, + orgId, + name: `default policy for ${resourceNiceId}`, + sso: true, + scope: "resource" + }) + .returning(); + + // Make the inline policy visible to the admin role + await trx.insert(rolePolicies).values({ + roleId: adminRole.roleId, + resourcePolicyId: inlinePolicy.resourcePolicyId + }); + + // Determine the active shared policy (if provided) + let sharedPolicyId: number | null = null; + if (resourceData.policy) { + const [sharedPolicy] = await trx + .select() + .from(resourcePolicies) + .where( + and( + eq(resourcePolicies.niceId, resourceData.policy), + eq(resourcePolicies.orgId, orgId) + ) + ) + .limit(1); + + if (!sharedPolicy) { + throw new Error( + `Shared policy not found: ${resourceData.policy} in org ${orgId}` + ); + } + sharedPolicyId = sharedPolicy.resourcePolicyId; + } + // Create new resource const [newResource] = await trx .insert(resources) @@ -744,8 +973,6 @@ export async function updateProxyResources( domainId: domain ? domain.domainId : null, wildcard: domain ? domain.wildcard : false, enabled: resourceEnabled, - sso: resourceData.auth?.["sso-enabled"] || false, - skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, ssl: resourceSsl, @@ -761,110 +988,147 @@ export async function updateProxyResources( maintenanceTitle: resourceData.maintenance?.title, maintenanceMessage: resourceData.maintenance?.message, maintenanceEstimatedTime: - resourceData.maintenance?.["estimated-time"] + resourceData.maintenance?.["estimated-time"], + defaultResourcePolicyId: inlinePolicy.resourcePolicyId, + resourcePolicyId: sharedPolicyId, + // Only set these resource-level fields when using a shared policy + ...(sharedPolicyId + ? { + sso: resourceData.auth?.["sso-enabled"] || false, + skipToIdpId: + resourceData.auth?.["auto-login-idp"] || null, + emailWhitelistEnabled: resourceData.auth?.[ + "whitelist-users" + ] + ? resourceData.auth["whitelist-users"] + .length > 0 + : false, + applyRules: + resourceData.rules && + resourceData.rules.length > 0 + } + : {}) }) .returning(); - if (resourceData.auth?.password) { - const passwordHash = await hashPassword( - resourceData.auth.password - ); - - await trx.insert(resourcePassword).values({ - resourceId: newResource.resourceId, - passwordHash - }); - } - - if (resourceData.auth?.pincode) { - const pincodeHash = await hashPassword( - resourceData.auth.pincode.toString() - ); - - await trx.insert(resourcePincode).values({ - resourceId: newResource.resourceId, - pincodeHash, - digitLength: 6 - }); - } - - if (resourceData.auth?.["basic-auth"]) { - const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; - const headerAuthPassword = - resourceData.auth?.["basic-auth"]?.password; - const headerAuthExtendedCompatibility = - resourceData.auth?.["basic-auth"]?.extendedCompatibility; - - if ( - headerAuthUser && - headerAuthPassword && - headerAuthExtendedCompatibility !== null - ) { - const headerAuthHash = await hashPassword( - Buffer.from( - `${headerAuthUser}:${headerAuthPassword}` - ).toString("base64") - ); - - await Promise.all([ - trx.insert(resourceHeaderAuth).values({ - resourceId: newResource.resourceId, - headerAuthHash - }), - trx - .insert(resourceHeaderAuthExtendedCompatibility) - .values({ - resourceId: newResource.resourceId, - extendedCompatibilityIsActivated: - headerAuthExtendedCompatibility - }) - ]); - } - } - resource = newResource; - const [adminRole] = await trx - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); - - if (!adminRole) { - throw new Error(`Admin role not found`); - } - await trx.insert(roleResources).values({ roleId: adminRole.roleId, resourceId: newResource.resourceId }); - if (resourceData.auth?.["sso-roles"]) { - const ssoRoles = resourceData.auth?.["sso-roles"]; - await syncRoleResources( - newResource.resourceId, - ssoRoles, + if (sharedPolicyId) { + // SHARED POLICY MODE: update OLD resource-level auth tables + if (resourceData.auth?.password) { + const passwordHash = await hashPassword( + resourceData.auth.password + ); + await trx.insert(resourcePassword).values({ + resourceId: newResource.resourceId, + passwordHash + }); + } + + if (resourceData.auth?.pincode) { + const pincodeHash = await hashPassword( + resourceData.auth.pincode.toString() + ); + await trx.insert(resourcePincode).values({ + resourceId: newResource.resourceId, + pincodeHash, + digitLength: 6 + }); + } + + if (resourceData.auth?.["basic-auth"]) { + const headerAuthUser = + resourceData.auth["basic-auth"]?.user; + const headerAuthPassword = + resourceData.auth["basic-auth"]?.password; + const headerAuthExtendedCompatibility = + resourceData.auth["basic-auth"]?.extendedCompatibility; + if ( + headerAuthUser && + headerAuthPassword && + headerAuthExtendedCompatibility !== null + ) { + const headerAuthHash = await hashPassword( + Buffer.from( + `${headerAuthUser}:${headerAuthPassword}` + ).toString("base64") + ); + await Promise.all([ + trx.insert(resourceHeaderAuth).values({ + resourceId: newResource.resourceId, + headerAuthHash + }), + trx + .insert(resourceHeaderAuthExtendedCompatibility) + .values({ + resourceId: newResource.resourceId, + extendedCompatibilityIsActivated: + headerAuthExtendedCompatibility + }) + ]); + } + } + + if (resourceData.auth?.["sso-roles"]) { + await syncRoleResources( + newResource.resourceId, + resourceData.auth["sso-roles"], + orgId, + trx + ); + } + + if (resourceData.auth?.["sso-users"]) { + await syncUserResources( + newResource.resourceId, + resourceData.auth["sso-users"], + orgId, + trx + ); + } + + if (resourceData.auth?.["whitelist-users"]) { + await syncWhitelistUsers( + newResource.resourceId, + resourceData.auth["whitelist-users"], + orgId, + trx + ); + } + + // Rules into OLD resourceRules table + for (const [index, rule] of resourceData.rules?.entries() || + []) { + validateRule(rule); + await trx.insert(resourceRules).values({ + resourceId: newResource.resourceId, + action: getRuleAction(rule.action), + match: rule.match.toUpperCase(), + value: getRuleValue( + rule.match.toUpperCase(), + rule.value + ), + priority: rule.priority ?? index + 1 + }); + } + } else { + // INLINE POLICY MODE: update the inline policy auth fields + await syncInlinePolicyAuth( + inlinePolicy.resourcePolicyId, orgId, + resourceData, trx ); - } - if (resourceData.auth?.["sso-users"]) { - const ssoUsers = resourceData.auth?.["sso-users"]; - await syncUserResources( - newResource.resourceId, - ssoUsers, - orgId, - trx - ); - } - - if (resourceData.auth?.["whitelist-users"]) { - const whitelistUsers = resourceData.auth?.["whitelist-users"]; - await syncWhitelistUsers( - newResource.resourceId, - whitelistUsers, - orgId, + // Rules into policy-level table + await syncInlinePolicyRules( + inlinePolicy.resourcePolicyId, + resourceData.rules || [], trx ); } @@ -878,17 +1142,6 @@ export async function updateProxyResources( await createTarget(newResource.resourceId, targetData); } - for (const [index, rule] of resourceData.rules?.entries() || []) { - validateRule(rule); - await trx.insert(resourceRules).values({ - resourceId: newResource.resourceId, - action: getRuleAction(rule.action), - match: rule.match.toUpperCase(), - value: getRuleValue(rule.match.toUpperCase(), rule.value), - priority: rule.priority ?? index + 1 - }); - } - logger.debug(`Created resource ${newResource.resourceId}`); } @@ -1126,6 +1379,399 @@ async function syncWhitelistUsers( } } +/** + * Creates an inline resourcePolicy if one doesn't exist yet, and returns its ID. + * Makes the policy visible to the admin role via rolePolicies. + */ +async function ensureInlinePolicy( + existingPolicyId: number | null | undefined, + orgId: string, + resourceNiceId: string, + adminRoleId: number, + trx: Transaction +): Promise { + if (existingPolicyId) { + return existingPolicyId; + } + + const policyNiceId = await getUniqueResourcePolicyName(orgId); + const [newPolicy] = await trx + .insert(resourcePolicies) + .values({ + niceId: policyNiceId, + orgId, + name: `default policy for ${resourceNiceId}`, + sso: true, + scope: "resource" + }) + .returning(); + + await trx.insert(rolePolicies).values({ + roleId: adminRoleId, + resourcePolicyId: newPolicy.resourcePolicyId + }); + + return newPolicy.resourcePolicyId; +} + +/** + * Updates the inline policy's auth-related fields and policy-level tables + * (used when no shared policy is specified in the blueprint). + */ +async function syncInlinePolicyAuth( + policyId: number, + orgId: string, + resourceData: any, + trx: Transaction +) { + // Update policy-level SSO/whitelist/applyRules fields + await trx + .update(resourcePolicies) + .set({ + sso: resourceData.auth?.["sso-enabled"] ?? false, + idpId: resourceData.auth?.["auto-login-idp"] || null, + emailWhitelistEnabled: resourceData.auth?.["whitelist-users"] + ? resourceData.auth["whitelist-users"].length > 0 + : false, + applyRules: !!(resourceData.rules && resourceData.rules.length > 0) + }) + .where(eq(resourcePolicies.resourcePolicyId, policyId)); + + // Password + await trx + .delete(resourcePolicyPassword) + .where(eq(resourcePolicyPassword.resourcePolicyId, policyId)); + if (resourceData.auth?.password) { + const passwordHash = await hashPassword(resourceData.auth.password); + await trx.insert(resourcePolicyPassword).values({ + resourcePolicyId: policyId, + passwordHash + }); + } + + // Pincode + await trx + .delete(resourcePolicyPincode) + .where(eq(resourcePolicyPincode.resourcePolicyId, policyId)); + if (resourceData.auth?.pincode) { + const pincodeHash = await hashPassword( + resourceData.auth.pincode.toString() + ); + await trx.insert(resourcePolicyPincode).values({ + resourcePolicyId: policyId, + pincodeHash, + digitLength: 6 + }); + } + + // Header auth + await trx + .delete(resourcePolicyHeaderAuth) + .where(eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId)); + if (resourceData.auth?.["basic-auth"]) { + const headerAuthUser = resourceData.auth["basic-auth"]?.user; + const headerAuthPassword = resourceData.auth["basic-auth"]?.password; + const headerAuthExtendedCompatibility = + resourceData.auth["basic-auth"]?.extendedCompatibility; + if ( + headerAuthUser && + headerAuthPassword && + headerAuthExtendedCompatibility !== null + ) { + const headerAuthHash = await hashPassword( + Buffer.from(`${headerAuthUser}:${headerAuthPassword}`).toString( + "base64" + ) + ); + await trx.insert(resourcePolicyHeaderAuth).values({ + resourcePolicyId: policyId, + headerAuthHash, + extendedCompatibility: headerAuthExtendedCompatibility + }); + } + } + + // SSO roles → rolePolicies + if (resourceData.auth?.["sso-roles"] !== undefined) { + await syncRolePolicies( + policyId, + resourceData.auth["sso-roles"], + orgId, + trx + ); + } + + // SSO users → userPolicies + if (resourceData.auth?.["sso-users"] !== undefined) { + await syncUserPolicies( + policyId, + resourceData.auth["sso-users"], + orgId, + trx + ); + } + + // Whitelist → resourcePolicyWhiteList + if (resourceData.auth?.["whitelist-users"] !== undefined) { + await syncWhitelistPolicyUsers( + policyId, + resourceData.auth["whitelist-users"], + orgId, + trx + ); + } +} + +/** + * Syncs rules into the resourcePolicyRules table (inline policy mode). + */ +async function syncInlinePolicyRules( + policyId: number, + rules: any[], + trx: Transaction +) { + const existingRules = await trx + .select() + .from(resourcePolicyRules) + .where(eq(resourcePolicyRules.resourcePolicyId, policyId)) + .orderBy(resourcePolicyRules.priority); + + for (const [index, rule] of rules.entries()) { + const intendedPriority = rule.priority ?? index + 1; + const existingRule = existingRules[index]; + if (existingRule) { + if ( + existingRule.action !== getRuleAction(rule.action) || + existingRule.match !== rule.match.toUpperCase() || + existingRule.value !== + getRuleValue(rule.match.toUpperCase(), rule.value) || + existingRule.priority !== intendedPriority + ) { + validateRule(rule); + await trx + .update(resourcePolicyRules) + .set({ + action: getRuleAction(rule.action) as + | "ACCEPT" + | "DROP" + | "PASS", + match: rule.match.toUpperCase() as + | "CIDR" + | "IP" + | "PATH", + value: getRuleValue( + rule.match.toUpperCase(), + rule.value + ), + priority: intendedPriority + }) + .where(eq(resourcePolicyRules.ruleId, existingRule.ruleId)); + } + } else { + validateRule(rule); + await trx.insert(resourcePolicyRules).values({ + resourcePolicyId: policyId, + action: getRuleAction(rule.action) as + | "ACCEPT" + | "DROP" + | "PASS", + match: rule.match.toUpperCase() as "CIDR" | "IP" | "PATH", + value: getRuleValue(rule.match.toUpperCase(), rule.value), + priority: intendedPriority + }); + } + } + + if (existingRules.length > rules.length) { + const rulesToDelete = existingRules.slice(rules.length); + for (const rule of rulesToDelete) { + await trx + .delete(resourcePolicyRules) + .where(eq(resourcePolicyRules.ruleId, rule.ruleId)); + } + } +} + +/** + * Syncs SSO roles to the rolePolicies table (inline policy mode). + */ +async function syncRolePolicies( + policyId: number, + ssoRoles: string[], + orgId: string, + trx: Transaction +) { + const existingRolePoliciesList = await trx + .select() + .from(rolePolicies) + .where(eq(rolePolicies.resourcePolicyId, policyId)); + + for (const roleName of ssoRoles) { + const [role] = await trx + .select() + .from(roles) + .where(and(eq(roles.name, roleName), eq(roles.orgId, orgId))) + .limit(1); + + if (!role) { + throw new Error(`Role not found: ${roleName} in org ${orgId}`); + } + + if (role.isAdmin) { + continue; + } + + const existingRolePolicy = existingRolePoliciesList.find( + (rp) => rp.roleId === role.roleId + ); + + if (!existingRolePolicy) { + await trx.insert(rolePolicies).values({ + roleId: role.roleId, + resourcePolicyId: policyId + }); + } + } + + for (const existingRolePolicy of existingRolePoliciesList) { + const [role] = await trx + .select() + .from(roles) + .where(eq(roles.roleId, existingRolePolicy.roleId)) + .limit(1); + + if (role?.isAdmin) { + continue; + } + + if (role && !ssoRoles.includes(role.name)) { + await trx + .delete(rolePolicies) + .where( + and( + eq(rolePolicies.roleId, existingRolePolicy.roleId), + eq(rolePolicies.resourcePolicyId, policyId) + ) + ); + } + } +} + +/** + * Syncs SSO users to the userPolicies table (inline policy mode). + */ +async function syncUserPolicies( + policyId: number, + ssoUsers: string[], + orgId: string, + trx: Transaction +) { + const existingUserPoliciesList = await trx + .select() + .from(userPolicies) + .where(eq(userPolicies.resourcePolicyId, policyId)); + + for (const username of ssoUsers) { + const [user] = await trx + .select() + .from(users) + .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) + .where( + and( + or(eq(users.username, username), eq(users.email, username)), + eq(userOrgs.orgId, orgId) + ) + ) + .limit(1); + + if (!user) { + throw new Error(`User not found: ${username} in org ${orgId}`); + } + + const existingUserPolicy = existingUserPoliciesList.find( + (up) => up.userId === user.user.userId + ); + + if (!existingUserPolicy) { + await trx.insert(userPolicies).values({ + userId: user.user.userId, + resourcePolicyId: policyId + }); + } + } + + for (const existingUserPolicy of existingUserPoliciesList) { + const [user] = await trx + .select() + .from(users) + .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) + .where( + and( + eq(users.userId, existingUserPolicy.userId), + eq(userOrgs.orgId, orgId) + ) + ) + .limit(1); + + if ( + user && + user.user.username && + !ssoUsers.includes(user.user.username) + ) { + await trx + .delete(userPolicies) + .where( + and( + eq(userPolicies.userId, existingUserPolicy.userId), + eq(userPolicies.resourcePolicyId, policyId) + ) + ); + } + } +} + +/** + * Syncs whitelist emails to the resourcePolicyWhiteList table (inline policy mode). + */ +async function syncWhitelistPolicyUsers( + policyId: number, + whitelistUsers: string[], + orgId: string, + trx: Transaction +) { + const existingWhitelist = await trx + .select() + .from(resourcePolicyWhiteList) + .where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId)); + + for (const email of whitelistUsers) { + const existingEntry = existingWhitelist.find((w) => w.email === email); + + if (!existingEntry) { + await trx.insert(resourcePolicyWhiteList).values({ + email, + resourcePolicyId: policyId + }); + } + } + + for (const existingEntry of existingWhitelist) { + if (!whitelistUsers.includes(existingEntry.email)) { + await trx + .delete(resourcePolicyWhiteList) + .where( + and( + eq( + resourcePolicyWhiteList.whitelistId, + existingEntry.whitelistId + ), + eq(resourcePolicyWhiteList.resourcePolicyId, policyId) + ) + ); + } + } +} + function checkIfHealthcheckChanged( existing: TargetHealthCheck | undefined, incoming: TargetHealthCheck | undefined diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 90f17dd9b..fc540a730 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -188,6 +188,7 @@ export const PublicResourceSchema = z .enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]) .optional(), // this was the old one and is now DEPRECATED in favor of the mode mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), + policy: z.string().optional(), ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).optional(), "full-domain": z.string().optional(), diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 48025e8e7..a7f32ffff 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -32,3 +32,4 @@ export * from "./verifySiteResourceAccess"; export * from "./logActionAudit"; export * from "./verifyOlmAccess"; export * from "./verifyLimits"; +export * from "./verifyResourcePolicyAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 8a213c6d2..d43854eab 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -16,3 +16,4 @@ export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyDomainAccess"; +export * from "./verifyApiKeyResourcePolicyAccess"; diff --git a/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts b/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts new file mode 100644 index 000000000..2d997de53 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyResourcePolicyAccess.ts @@ -0,0 +1,92 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resourcePolicies, apiKeyOrg } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyResourcePolicyAccess( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const resourcePolicyId = + req.params.resourcePolicyId || + req.body.resourcePolicyId || + req.query.resourcePolicyId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + try { + // Retrieve the resource policy + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource policy with ID ${resourcePolicyId} not found` + ) + ); + } + + if (apiKey.isRoot) { + // Root keys can access any resource policy in any org + return next(); + } + + if (!policy.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource policy with ID ${resourcePolicyId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource policy's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, policy.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource policy access" + ) + ); + } +} diff --git a/server/middlewares/verifyResourcePolicyAccess.ts b/server/middlewares/verifyResourcePolicyAccess.ts new file mode 100644 index 000000000..30fe48e8c --- /dev/null +++ b/server/middlewares/verifyResourcePolicyAccess.ts @@ -0,0 +1,127 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resourcePolicies, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; + +export async function verifyResourcePolicyAccess( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; + const resourcePolicyIdStr = + req.params?.resourcePolicyId || + req.body?.resourcePolicyId || + req.query?.resourcePolicyId; + const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId; + const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId; + + try { + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + let policy: typeof resourcePolicies.$inferSelect | null = null; + + if (orgId && niceId) { + const [policyRes] = await db + .select() + .from(resourcePolicies) + .where( + and( + eq(resourcePolicies.niceId, niceId), + eq(resourcePolicies.orgId, orgId) + ) + ) + .limit(1); + policy = policyRes ?? null; + } else { + const resourcePolicyId = parseInt(resourcePolicyIdStr); + if (isNaN(resourcePolicyId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid resource policy ID" + ) + ); + } + const [policyRes] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + policy = policyRes ?? null; + } + + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource policy with ID ${resourcePolicyIdStr ?? niceId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRes = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, policy.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRes[0]; + } + + if (!req.userOrg || req.userOrg.orgId !== policy.orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + policy.orgId + ); + req.userOrgId = policy.orgId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource policy access" + ) + ); + } +} diff --git a/server/middlewares/verifyUserCanSetUserOrgRoles.ts b/server/middlewares/verifyUserCanSetUserOrgRoles.ts index 1a7554ab3..3b8687b96 100644 --- a/server/middlewares/verifyUserCanSetUserOrgRoles.ts +++ b/server/middlewares/verifyUserCanSetUserOrgRoles.ts @@ -38,7 +38,7 @@ export function verifyUserCanSetUserOrgRoles() { return next( createHttpError( HttpCode.FORBIDDEN, - "User does not have permission perform this action" + "User does not have permission to set user organization roles" ) ); } catch (error) { diff --git a/server/openApi.ts b/server/openApi.ts index 26c9e2f2e..7132a0327 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -7,6 +7,7 @@ export enum OpenAPITags { Org = "Organization", PublicResource = "Public Resource", PrivateResource = "Private Resource", + Policy = "Policy", Role = "Role", User = "User", Invitation = "User Invitation", diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 1b2bed656..82d63d7dd 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -34,6 +34,8 @@ import * as healthChecks from "#private/routers/healthChecks"; import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import * as labels from "#private/routers/labels"; import * as client from "@server/routers/client"; +import * as resource from "#private/routers/resource"; +import * as policy from "#private/routers/policy"; import { verifyOrgAccess, @@ -47,7 +49,8 @@ import { verifyUserCanSetUserOrgRoles, verifySiteProvisioningKeyAccess, verifyIsLoggedInUser, - verifyAdmin + verifyAdmin, + verifyResourcePolicyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -385,6 +388,39 @@ authenticated.get( approval.countApprovals ); +authenticated.delete( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyLimits, + verifyUserHasAction(ActionsEnum.deleteResourcePolicy), + logActionAudit(ActionsEnum.deleteResourcePolicy), + policy.deleteResourcePolicy +); + +authenticated.get( + "/org/:orgId/resource-policies", + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.listResourcePolicies), + logActionAudit(ActionsEnum.listResourcePolicies), + policy.listResourcePolicies +); + +authenticated.post( + "/org/:orgId/resource-policy", + verifyValidLicense, + verifyValidSubscription(tierMatrix.resourcePolicies), + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createResourcePolicy), + logActionAudit(ActionsEnum.createResourcePolicy), + policy.createResourcePolicy +); + authenticated.put( "/org/:orgId/approvals/:approvalId", verifyValidLicense, diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 98e7ff671..508952341 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -45,8 +45,11 @@ import { users, userOrgs, roleResources, + rolePolicies, userResources, + userPolicies, resourceRules, + resourcePolicyRules, userOrgRoles, roles } from "@server/db"; @@ -430,7 +433,10 @@ hybridRouter.get( ); // Decrypt and save key file - const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!); + const decryptedKey = decrypt( + cert.keyFile!, + config.getRawConfig().server.secret! + ); // Return only the certificate data without org information return { @@ -531,7 +537,10 @@ hybridRouter.get( wildcardCandidates.length > 0 ? and( eq(resources.wildcard, true), - inArray(resources.fullDomain, wildcardCandidates) + inArray( + resources.fullDomain, + wildcardCandidates + ) ) : sql`false` ) @@ -545,10 +554,10 @@ hybridRouter.get( if ( result && - await checkExitNodeOrg( + (await checkExitNodeOrg( remoteExitNode.exitNodeId, result.resources.orgId - ) + )) ) { // If the exit node is not allowed for the org, return an error return next( @@ -1132,22 +1141,43 @@ hybridRouter.get( ); } - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) + const [direct, viaPolicies] = await Promise.all([ + db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) ) - ) - .limit(1); + .limit(1), + db + .select({ + roleId: rolePolicies.roleId, + resourcePolicyId: rolePolicies.resourcePolicyId + }) + .from(rolePolicies) + .innerJoin( + resources, + eq( + resources.resourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + .where( + and( + eq(resources.resourceId, resourceId), + eq(rolePolicies.roleId, roleId) + ) + ) + .limit(1) + ]); - const result = - roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; + const result = direct[0] ?? viaPolicies[0] ?? null; return response(res, { - data: result, + data: result as any, success: true, error: false, message: result @@ -1222,21 +1252,44 @@ hybridRouter.get( ); } - const roleResourceAccess = await db - .select({ - resourceId: roleResources.resourceId, - roleId: roleResources.roleId - }) - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - inArray(roleResources.roleId, roleIds) - ) - ); + const [direct, viaPolicies] = await Promise.all([ + db + .select({ + resourceId: roleResources.resourceId, + roleId: roleResources.roleId + }) + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ), + roleIds.length > 0 + ? db + .select({ + resourceId: sql`${resourceId}`, + roleId: rolePolicies.roleId + }) + .from(rolePolicies) + .innerJoin( + resources, + eq( + resources.resourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + .where( + and( + eq(resources.resourceId, resourceId), + inArray(rolePolicies.roleId, roleIds) + ) + ) + : Promise.resolve([]) + ]); - const result = - roleResourceAccess.length > 0 ? roleResourceAccess : null; + const combined = [...direct, ...viaPolicies]; + const result = combined.length > 0 ? combined : null; return response<{ resourceId: number; roleId: number }[] | null>( res, @@ -1397,10 +1450,45 @@ hybridRouter.get( ); } - const rules = await db - .select() - .from(resourceRules) - .where(eq(resourceRules.resourceId, resourceId)); + const [directRules, policyRules] = await Promise.all([ + db + .select() + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)), + db + .select({ + ruleId: resourcePolicyRules.ruleId, + resourceId: sql`${resourceId}`, + enabled: resourcePolicyRules.enabled, + priority: resourcePolicyRules.priority, + action: resourcePolicyRules.action, + match: resourcePolicyRules.match, + value: resourcePolicyRules.value + }) + .from(resourcePolicyRules) + .innerJoin( + resources, + eq( + resources.resourcePolicyId, + resourcePolicyRules.resourcePolicyId + ) + ) + .where(eq(resources.resourceId, resourceId)) + ]); + + const maxDirectPriority = directRules.reduce( + (max, r) => Math.max(max, r.priority), + 0 + ); + const offsetPolicyRules = policyRules.map((r) => ({ + ...r, + priority: maxDirectPriority + r.priority + })); + + const rules = [ + ...directRules, + ...offsetPolicyRules + ] as (typeof resourceRules.$inferSelect)[]; // backward compatibility: COUNTRY -> GEOIP // TODO: remove this after a few versions once all exit nodes are updated diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts new file mode 100644 index 000000000..2b4678331 --- /dev/null +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -0,0 +1,417 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 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 { 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.userOrgRoleIds?.length === 0) { + 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[]; + + rolesToAdd.push( + ...existingRoles.map((role) => ({ + roleId: role.roleId, + resourcePolicyId: newPolicy.resourcePolicyId + })) + ); + + await trx.insert(rolePolicies).values(rolesToAdd); + + const usersToAdd: InferInsertModel[] = []; + + if ( + req.user && + !req.userOrgRoleIds?.includes(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(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/deleteResourcePolicy.ts b/server/private/routers/policy/deleteResourcePolicy.ts new file mode 100644 index 000000000..a586cf3b4 --- /dev/null +++ b/server/private/routers/policy/deleteResourcePolicy.ts @@ -0,0 +1,107 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 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 { + 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") + ); + } +} diff --git a/server/private/routers/policy/index.ts b/server/private/routers/policy/index.ts new file mode 100644 index 000000000..c780ebfe4 --- /dev/null +++ b/server/private/routers/policy/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 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"; diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts new file mode 100644 index 000000000..beb1b68c3 --- /dev/null +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -0,0 +1,271 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 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() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + 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 { + 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`COALESCE(${userPolicies.resourcePolicyId}, ${rolePolicies.resourcePolicyId})` + }) + .from(userPolicies) + .fullJoin( + rolePolicies, + eq( + userPolicies.resourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + .where( + or( + eq(userPolicies.userId, req.user!.userId), + inArray(rolePolicies.roleId, req.userOrgRoleIds || []) + ) + ); + } 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(); + + 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(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") + ); + } +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index d3c110728..e983ee03c 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -671,7 +671,8 @@ export async function verifyResourceSession( resourceData.org ); - localCache.set(userAccessCacheKey, allowedUserData, 5); + // this is query intensive so let it cache a little longer + localCache.set(userAccessCacheKey, allowedUserData, 12); } if ( @@ -1003,11 +1004,7 @@ async function checkRules( isIpInCidr(clientIp, rule.value) ) { return rule.action as any; - } else if ( - clientIp && - rule.match == "IP" && - clientIp == rule.value - ) { + } else if (clientIp && rule.match == "IP" && clientIp == rule.value) { return rule.action as any; } else if ( path && @@ -1015,10 +1012,7 @@ async function checkRules( isPathAllowed(rule.value, path) ) { return rule.action as any; - } else if ( - clientIp && - rule.match == "COUNTRY" - ) { + } else if (clientIp && rule.match == "COUNTRY") { // COUNTRY=ALL should not affect local/private/CGNAT addresses. if ( rule.value.toUpperCase() === "ALL" && @@ -1030,10 +1024,7 @@ async function checkRules( if (await isIpInGeoIP(ipCC, rule.value)) { return rule.action as any; } - } else if ( - clientIp && - rule.match == "ASN" - ) { + } else if (clientIp && rule.match == "ASN") { // ASN=ALL/AS0 should not affect local/private/CGNAT addresses. if ( (rule.value.toUpperCase() === "ALL" || @@ -1272,11 +1263,15 @@ export async function isIpInRegion( if (region.id === checkRegionCode) { for (const subregion of region.includes) { if (subregion.countries.includes(upperCode)) { - logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`); + logger.debug( + `Country ${upperCode} is in region ${region.id} (${region.name})` + ); return true; } } - logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`); + logger.debug( + `Country ${upperCode} is not in region ${region.id} (${region.name})` + ); return false; } @@ -1284,10 +1279,14 @@ export async function isIpInRegion( for (const subregion of region.includes) { if (subregion.id === checkRegionCode) { if (subregion.countries.includes(upperCode)) { - logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`); + logger.debug( + `Country ${upperCode} is in region ${subregion.id} (${subregion.name})` + ); return true; } - logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`); + logger.debug( + `Country ${upperCode} is not in region ${subregion.id} (${subregion.name})` + ); return false; } } diff --git a/server/routers/external.ts b/server/routers/external.ts index 9a1730b9c..440bb5f21 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -3,6 +3,7 @@ import config from "@server/lib/config"; import * as site from "./site"; import * as org from "./org"; import * as resource from "./resource"; +import * as policy from "./policy"; import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; @@ -42,7 +43,8 @@ import { verifyUserIsOrgOwner, verifySiteResourceAccess, verifyOlmAccess, - verifyLimits + verifyLimits, + verifyResourcePolicyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -103,7 +105,6 @@ authenticated.put( site.createSite ); - authenticated.get( "/org/:orgId/sites", verifyOrgAccess, @@ -540,6 +541,7 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); + authenticated.post( "/resource/:resourceId", verifyResourceAccess, @@ -646,6 +648,29 @@ authenticated.post( logActionAudit(ActionsEnum.updateRole), role.updateRole ); + +authenticated.get( + "/org/:orgId/resource-policy/:niceId", + verifyOrgAccess, + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + +authenticated.get( + "/resource/:resourceId/policies", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicy), + resource.getResourcePolicies +); + +authenticated.put( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.updateResourcePolicy), + policy.updateResourcePolicy +); + // authenticated.get( // "/role/:roleId", // verifyRoleAccess, @@ -697,6 +722,59 @@ authenticated.post( resource.setResourceUsers ); +authenticated.put( + "/resource-policy/:resourcePolicyId/access-control", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + logActionAudit(ActionsEnum.setResourcePolicyUsers), + policy.setResourcePolicyAccessControl +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/password", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyPassword), + logActionAudit(ActionsEnum.setResourcePolicyPassword), + policy.setResourcePolicyPassword +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/pincode", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyPincode), + logActionAudit(ActionsEnum.setResourcePolicyPincode), + policy.setResourcePolicyPincode +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/header-auth", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyHeaderAuth), + logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth), + policy.setResourcePolicyHeaderAuth +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/whitelist", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyWhitelist), + logActionAudit(ActionsEnum.setResourcePolicyWhitelist), + policy.setResourcePolicyWhitelist +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/rules", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyRules), + logActionAudit(ActionsEnum.setResourcePolicyRules), + policy.setResourcePolicyRules +); + authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 2865b4bcb..ceec501f9 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -2,6 +2,7 @@ import * as site from "./site"; import * as org from "./org"; import * as blueprints from "./blueprints"; import * as resource from "./resource"; +import * as policy from "./policy"; import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; @@ -29,7 +30,9 @@ import { verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, - verifyApiKeyDomainAccess + verifyApiKeyDomainAccess, + verifyApiKeyResourcePolicyAccess, + verifyUserHasAction } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -459,6 +462,20 @@ authenticated.get( resource.getResource ); +authenticated.get( + "/resource-policy/:resourcePolicyId", + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + +authenticated.get( + "/resource/:resourceId/policies", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResourcePolicy), + resource.getResourcePolicies +); + authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, @@ -468,6 +485,13 @@ authenticated.post( resource.updateResource ); +authenticated.put( + "/resource-policy/:resourcePolicyId", + verifyApiKeyResourcePolicyAccess, + verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy), + policy.updateResourcePolicy +); + authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, @@ -619,6 +643,63 @@ authenticated.post( resource.setResourceUsers ); +authenticated.put( + "/resource-policy/:resourcePolicyId/access-control", + verifyApiKeyResourcePolicyAccess, + verifyApiKeyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + logActionAudit(ActionsEnum.setResourcePolicyUsers), + logActionAudit(ActionsEnum.setResourcePolicyRoles), + policy.setResourcePolicyAccessControl +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/password", + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword), + logActionAudit(ActionsEnum.setResourcePolicyPassword), + policy.setResourcePolicyPassword +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/pincode", + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode), + logActionAudit(ActionsEnum.setResourcePolicyPincode), + policy.setResourcePolicyPincode +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/header-auth", + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth), + logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth), + policy.setResourcePolicyHeaderAuth +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/whitelist", + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist), + logActionAudit(ActionsEnum.setResourcePolicyWhitelist), + policy.setResourcePolicyWhitelist +); + +authenticated.put( + "/resource-policy/:resourcePolicyId/rules", + verifyApiKeyResourcePolicyAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules), + logActionAudit(ActionsEnum.setResourcePolicyRules), + policy.setResourcePolicyRules +); + authenticated.post( "/resource/:resourceId/roles/add", verifyApiKeyResourceAccess, diff --git a/server/routers/policy/getResourcePolicy.ts b/server/routers/policy/getResourcePolicy.ts new file mode 100644 index 000000000..d7513d58d --- /dev/null +++ b/server/routers/policy/getResourcePolicy.ts @@ -0,0 +1,231 @@ +import { + db, + idp, + resourcePolicyRules, + resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, + resourcePolicyWhiteList, + rolePolicies, + roles, + userPolicies, + users +} 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 { and, eq, isNull, not, or, type SQL } 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"; + +const getResourcePolicySchema = z + .strictObject({ + niceId: z.string(), + orgId: z.string() + }) + .or( + z.strictObject({ + resourcePolicyId: z.coerce + .number() + .int() + .positive() + .openapi({ + type: "integer", + description: "Resource policy ID" + }) + }) + ); + +export async function queryResourcePolicy( + params: z.infer +) { + const conditions: SQL[] = []; + if ("resourcePolicyId" in params) { + conditions.push( + eq(resourcePolicies.resourcePolicyId, params.resourcePolicyId) + ); + } else { + conditions.push( + eq(resourcePolicies.niceId, params.niceId), + eq(resourcePolicies.orgId, params.orgId) + ); + } + + const [res] = await db + .select({ + resourcePolicyId: resourcePolicies.resourcePolicyId, + sso: resourcePolicies.sso, + applyRules: resourcePolicies.applyRules, + emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled, + idpId: resourcePolicies.idpId, + niceId: resourcePolicies.niceId, + name: resourcePolicies.name, + passwordId: resourcePolicyPassword.passwordId, + pincodeId: resourcePolicyPincode.pincodeId, + headerAuth: { + id: resourcePolicyHeaderAuth.headerAuthId, + extendedCompability: + resourcePolicyHeaderAuth.extendedCompatibility + } + }) + .from(resourcePolicies) + .leftJoin( + resourcePolicyPassword, + eq( + resourcePolicyPassword.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyPincode, + eq( + resourcePolicyPincode.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyHeaderAuth, + eq( + resourcePolicyHeaderAuth.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .where(and(...conditions)) + .limit(1); + + if (!res) return null; + + const policyUsers = await db + .select({ + userId: userPolicies.userId, + email: users.email, + name: users.name, + username: users.username, + type: users.type, + idpName: idp.name + }) + .from(userPolicies) + .innerJoin(users, eq(userPolicies.userId, users.userId)) + .leftJoin(idp, eq(idp.idpId, users.idpId)) + .where(eq(userPolicies.resourcePolicyId, res.resourcePolicyId)); + + const policyRoles = await db + .select({ + roleId: rolePolicies.roleId, + name: roles.name + }) + .from(rolePolicies) + .innerJoin( + roles, + and( + eq(rolePolicies.roleId, roles.roleId), + or(isNull(roles.isAdmin), not(roles.isAdmin)) + ) + ) + .where(eq(rolePolicies.resourcePolicyId, res.resourcePolicyId)); + + const policyEmailWhiteList = await db + .select({ + whiteListId: resourcePolicyWhiteList.whitelistId, + email: resourcePolicyWhiteList.email + }) + .from(resourcePolicyWhiteList) + .where( + eq(resourcePolicyWhiteList.resourcePolicyId, res.resourcePolicyId) + ); + + const policyRules = await db + .select({ + ruleId: resourcePolicyRules.ruleId, + enabled: resourcePolicyRules.enabled, + priority: resourcePolicyRules.priority, + action: resourcePolicyRules.action, + match: resourcePolicyRules.match, + value: resourcePolicyRules.value + }) + .from(resourcePolicyRules) + .where(eq(resourcePolicyRules.resourcePolicyId, res.resourcePolicyId)); + + return { + ...res, + roles: policyRoles, + users: policyUsers, + emailWhiteList: policyEmailWhiteList, + rules: policyRules + }; +} + +export type GetResourcePolicyResponse = NonNullable< + Awaited> +>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resource-policy/{niceId}", + description: + "Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", + tags: [OpenAPITags.Org, OpenAPITags.Policy], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/resource-policy/{resourcePolicyId}", + description: "Get a resource policy by its resourcePolicyId.", + tags: [OpenAPITags.Policy], + request: { + params: z.object({ + resourcePolicyId: z.number() + }) + }, + responses: {} +}); + +export async function getResourcePolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getResourcePolicySchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const policy = await queryResourcePolicy(parsedParams.data); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + return response(res, { + data: policy, + success: true, + error: false, + message: "Resource Policy 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/policy/index.ts b/server/routers/policy/index.ts new file mode 100644 index 000000000..2ebe6da7e --- /dev/null +++ b/server/routers/policy/index.ts @@ -0,0 +1,8 @@ +export * from "./getResourcePolicy"; +export * from "./updateResourcePolicy"; +export * from "./setResourcePolicyAccessControl"; +export * from "./setResourcePolicyPassword"; +export * from "./setResourcePolicyPincode"; +export * from "./setResourcePolicyHeaderAuth"; +export * from "./setResourcePolicyWhitelist"; +export * from "./setResourcePolicyRules"; diff --git a/server/routers/policy/setResourcePolicyAccessControl.ts b/server/routers/policy/setResourcePolicyAccessControl.ts new file mode 100644 index 000000000..6c0e19b68 --- /dev/null +++ b/server/routers/policy/setResourcePolicyAccessControl.ts @@ -0,0 +1,237 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + idp, + idpOrg, + resourcePolicies, + rolePolicies, + roles, + userOrgs, + users +} from "@server/db"; +import { userPolicies } 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 { and, eq, inArray, ne } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyAcccessControlBodySchema = z.strictObject({ + sso: z.boolean(), + userIds: z.array(z.string()), + roleIds: z.array(z.int().positive()).openapi({ + type: "array" + }), + skipToIdpId: z.int().positive().optional().nullable().openapi({ + type: "integer", + description: "Page number to retrieve" + }) +}); + +const setResourcePolicyAccessControlParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/resource-policy/{resourceId}/access-control", + description: + "Set access control users for a resource policy, including SSO, users, roles, Identity provider.", + tags: [OpenAPITags.Policy, OpenAPITags.User], + request: { + params: setResourcePolicyAccessControlParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyAcccessControlBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyAccessControl( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourcePolicyAcccessControlBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userIds, roleIds, sso, skipToIdpId: idpId } = parsedBody.data; + + const parsedParams = + setResourcePolicyAccessControlParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Resource policy not found" + ) + ); + } + + // Check if Identity provider in `skipToIdpId` exists + if (idpId) { + const [provider] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId)) + .where( + and(eq(idp.idpId, idpId), eq(idpOrg.orgId, policy.orgId)) + ) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Identity provider not found in this organization" + ) + ); + } + } + + // Check if any of the roleIds are admin roles + const rolesToCheck = await db + .select() + .from(roles) + .where( + and( + inArray(roles.roleId, roleIds), + eq(roles.orgId, policy.orgId) + ) + ); + + const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); + + if (hasAdminRole) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to resources" + ) + ); + } + + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await db + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, policy.orgId))); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + const existingUsers = await db + .select() + .from(users) + .innerJoin(userOrgs, eq(userOrgs.userId, users.userId)) + .where( + and( + eq(userOrgs.orgId, policy.orgId), + inArray(users.userId, userIds) + ) + ); + + const existingRoles = await db + .select() + .from(roles) + .where( + and( + eq(roles.orgId, policy.orgId), + inArray(roles.roleId, roleIds) + ) + ); + + await db.transaction(async (trx) => { + // Update SSO status + await trx + .update(resourcePolicies) + .set({ + sso, + idpId + }) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + + // Update roles + if (adminRoleIds.length > 0) { + await trx.delete(rolePolicies).where( + and( + eq(rolePolicies.resourcePolicyId, resourcePolicyId), + ne(rolePolicies.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx + .delete(rolePolicies) + .where(eq(rolePolicies.resourcePolicyId, resourcePolicyId)); + } + + const rolesToAdd = existingRoles.map(({ roleId }) => ({ + roleId, + resourcePolicyId + })); + + if (rolesToAdd.length > 0) { + await trx.insert(rolePolicies).values(rolesToAdd); + } + + // Update users + await trx + .delete(userPolicies) + .where(eq(userPolicies.resourcePolicyId, resourcePolicyId)); + + const usersToAdd = existingUsers.map(({ user }) => ({ + userId: user.userId, + resourcePolicyId: resourcePolicyId + })); + + if (usersToAdd.length > 0) { + await trx.insert(userPolicies).values(usersToAdd); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Resource policy succesfully updated", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/setResourcePolicyHeaderAuth.ts b/server/routers/policy/setResourcePolicyHeaderAuth.ts new file mode 100644 index 000000000..368f9b05e --- /dev/null +++ b/server/routers/policy/setResourcePolicyHeaderAuth.ts @@ -0,0 +1,117 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourcePolicyHeaderAuth } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import { response } from "@server/lib/response"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyHeaderAuthParamsSchema = z.object({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const setResourcePolicyHeaderAuthBodySchema = z.strictObject({ + headerAuth: z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() + }) + .nullable() +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/header-auth", + description: + "Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyHeaderAuthParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyHeaderAuthBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyHeaderAuth( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setResourcePolicyHeaderAuthParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setResourcePolicyHeaderAuthBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + const { headerAuth } = parsedBody.data; + + await db.transaction(async (trx) => { + await trx + .delete(resourcePolicyHeaderAuth) + .where( + eq( + resourcePolicyHeaderAuth.resourcePolicyId, + resourcePolicyId + ) + ); + + if (headerAuth !== null) { + const headerAuthHash = await hashPassword( + Buffer.from( + `${headerAuth.user}:${headerAuth.password}` + ).toString("base64") + ); + + await trx.insert(resourcePolicyHeaderAuth).values({ + resourcePolicyId, + headerAuthHash, + extendedCompatibility: headerAuth.extendedCompatibility + }); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Header Authentication set successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/setResourcePolicyPassword.ts b/server/routers/policy/setResourcePolicyPassword.ts new file mode 100644 index 000000000..dd436a7bf --- /dev/null +++ b/server/routers/policy/setResourcePolicyPassword.ts @@ -0,0 +1,106 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourcePolicyPassword } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import { response } from "@server/lib/response"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyPasswordParamsSchema = z.object({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const setResourcePolicyPasswordBodySchema = z.strictObject({ + password: z.string().min(4).max(100).nullable() +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/password", + description: + "Set the password for a resource policy. Setting the password to null will remove it.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyPasswordParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPasswordBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyPassword( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setResourcePolicyPasswordParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setResourcePolicyPasswordBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + const { password } = parsedBody.data; + + await db.transaction(async (trx) => { + await trx + .delete(resourcePolicyPassword) + .where( + eq( + resourcePolicyPassword.resourcePolicyId, + resourcePolicyId + ) + ); + + if (password) { + const passwordHash = await hashPassword(password); + + await trx + .insert(resourcePolicyPassword) + .values({ resourcePolicyId, passwordHash }); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Resource policy password set successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/setResourcePolicyPincode.ts b/server/routers/policy/setResourcePolicyPincode.ts new file mode 100644 index 000000000..f99691275 --- /dev/null +++ b/server/routers/policy/setResourcePolicyPincode.ts @@ -0,0 +1,106 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourcePolicyPincode } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import { response } from "@server/lib/response"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyPincodeParamsSchema = z.object({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const setResourcePolicyPincodeBodySchema = z.strictObject({ + pincode: z + .string() + .regex(/^\d{6}$/) + .or(z.null()) +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/pincode", + description: + "Set the PIN code for a resource policy. Setting the PIN code to null will remove it.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyPincodeParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyPincodeBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyPincode( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setResourcePolicyPincodeParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setResourcePolicyPincodeBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + const { pincode } = parsedBody.data; + + await db.transaction(async (trx) => { + await trx + .delete(resourcePolicyPincode) + .where( + eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId) + ); + + if (pincode) { + const pincodeHash = await hashPassword(pincode); + + await trx + .insert(resourcePolicyPincode) + .values({ resourcePolicyId, pincodeHash, digitLength: 6 }); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Resource policy PIN code set successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts new file mode 100644 index 000000000..533e01c0e --- /dev/null +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -0,0 +1,167 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourcePolicyRules, resourcePolicies } 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 { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { OpenAPITags, registry } from "@server/openApi"; + +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 setResourcePolicyRulesBodySchema = z.strictObject({ + applyRules: z.boolean(), + rules: z.array(ruleSchema) +}); + +const setResourcePolicyRulesParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/rules", + description: + "Set all rules for a resource policy at once. This will replace all existing rules.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyRulesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRulesBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyRules( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setResourcePolicyRulesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setResourcePolicyRulesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + const { applyRules, rules } = parsedBody.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + 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" + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .update(resourcePolicies) + .set({ applyRules }) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + + await trx + .delete(resourcePolicyRules) + .where( + eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId) + ); + + if (rules.length > 0) { + await trx.insert(resourcePolicyRules).values( + rules.map((rule) => ({ + resourcePolicyId, + ...rule + })) + ); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Resource policy rules set successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/setResourcePolicyWhitelist.ts b/server/routers/policy/setResourcePolicyWhitelist.ts new file mode 100644 index 000000000..0bffacec2 --- /dev/null +++ b/server/routers/policy/setResourcePolicyWhitelist.ts @@ -0,0 +1,132 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourcePolicies, resourcePolicyWhiteList } 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 { and, eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyWhitelistBodySchema = z.strictObject({ + emailWhitelistEnabled: z.boolean(), + 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())) +}); + +const setResourcePolicyWhitelistParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "put", + path: "/resource-policy/{resourcePolicyId}/whitelist", + description: + "Set email whitelist for a resource policy. This will replace all existing emails.", + tags: [OpenAPITags.Policy], + request: { + params: setResourcePolicyWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyWhitelistBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyWhitelist( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourcePolicyWhitelistBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = setResourcePolicyWhitelistParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + const { emailWhitelistEnabled, emails } = parsedBody.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + await db.transaction(async (trx) => { + await trx + .update(resourcePolicies) + .set({ emailWhitelistEnabled }) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)); + + // delete all whitelist emails + await trx + .delete(resourcePolicyWhiteList) + .where( + eq( + resourcePolicyWhiteList.resourcePolicyId, + resourcePolicyId + ) + ); + + if (emailWhitelistEnabled && emails.length > 0) { + await trx.insert(resourcePolicyWhiteList).values( + emails.map((email) => ({ + email, + resourcePolicyId + })) + ); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Whitelist set for resource policy successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/policy/updateResourcePolicy.ts b/server/routers/policy/updateResourcePolicy.ts new file mode 100644 index 000000000..ad8b19639 --- /dev/null +++ b/server/routers/policy/updateResourcePolicy.ts @@ -0,0 +1,157 @@ +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, 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.userOrgRoleIds?.length === 0) { + 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/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index aeec8b1a9..ea3fdecb2 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -1,15 +1,19 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, domainNamespaces, loginPage } from "@server/db"; +import { build } from "@server/build"; import { - domains, - orgDomains, + db, + loginPage, orgs, Resource, resources, + resourcePolicies, roleResources, + rolePolicies, roles, - userResources + userPolicies, + userResources, + domainNamespaces } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -20,9 +24,7 @@ import logger from "@server/logger"; import { subdomainSchema, wildcardSubdomainSchema } 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 { validateAndConstructDomain, checkWildcardDomainConflict @@ -30,6 +32,10 @@ import { import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + getUniqueResourceName, + getUniqueResourcePolicyName +} from "@server/db/names"; const createResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -391,8 +397,46 @@ async function createHttpResource( let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); + const policyNiceId = await getUniqueResourcePolicyName(orgId); await db.transaction(async (trx) => { + const adminRole = await trx + .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 [defaultPolicy] = await trx + .insert(resourcePolicies) + .values({ + niceId: policyNiceId, + orgId, + name: `default policy for ${niceId}`, + sso: true, + scope: "resource" + }) + .returning(); + + // make this policy visible by the admin role + await trx.insert(rolePolicies).values({ + roleId: adminRole[0].roleId, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + + // make this policy visible by the current user + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { + await trx.insert(userPolicies).values({ + userId: req.user?.userId!, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + } + const newResource = await trx .insert(resources) .values({ @@ -410,22 +454,11 @@ async function createHttpResource( stickySession: stickySession, postAuthPath: postAuthPath, wildcard, - health: "unknown" + health: "unknown", + defaultResourcePolicyId: defaultPolicy.resourcePolicyId }) .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 trx.insert(roleResources).values({ roleId: adminRole[0].roleId, resourceId: newResource[0].resourceId @@ -451,7 +484,7 @@ async function createHttpResource( ); } - if (build != "oss") { + if (build !== "oss") { await createCertificate(domainId, fullDomain, db); } @@ -502,21 +535,10 @@ async function createRawResource( let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); + const policyNiceId = await getUniqueResourcePolicyName(orgId); await db.transaction(async (trx) => { - const newResource = await trx - .insert(resources) - .values({ - niceId, - orgId, - name, - proxyPort, - mode: resolvedMode.mode - // enableProxy - }) - .returning(); - - const adminRole = await db + const adminRole = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) @@ -528,6 +550,43 @@ async function createRawResource( ); } + const [defaultPolicy] = await trx + .insert(resourcePolicies) + .values({ + niceId: policyNiceId, + orgId, + name: `default policy for ${niceId}`, + sso: true, + scope: "resource" + }) + .returning(); + + // make this policy visible by the admin role + await trx.insert(rolePolicies).values({ + roleId: adminRole[0].roleId, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + + // make this policy visible by the current user + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { + await trx.insert(userPolicies).values({ + userId: req.user?.userId!, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + } + + const newResource = await trx + .insert(resources) + .values({ + niceId, + orgId, + name, + mode: resolvedMode.mode, + proxyPort, + defaultResourcePolicyId: defaultPolicy.resourcePolicyId + }) + .returning(); + await trx.insert(roleResources).values({ roleId: adminRole[0].roleId, resourceId: newResource[0].resourceId diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index ca644d299..b939cdad4 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,17 +1,22 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db, targetHealthCheck } from "@server/db"; -import { newts, resources, sites, targets } from "@server/db"; import { eq, inArray } from "drizzle-orm"; +import { + db, + newts, + resourcePolicies, + resources, + sites, + targetHealthCheck, + targets +} 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 { addPeer } from "../gerbil/peers"; -import { removeTargets } from "../newt/targets"; -import { getAllowedIps } from "../target/helpers"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { removeTargets } from "../newt/targets"; // Define Zod schema for request parameters validation const deleteResourceSchema = z.strictObject({ @@ -113,6 +118,18 @@ export async function deleteResource( } } + // Also delete default resource policy + if (deletedResource.defaultResourcePolicyId) { + await db + .delete(resourcePolicies) + .where( + eq( + resourcePolicies.resourcePolicyId, + deletedResource.defaultResourcePolicyId + ) + ); + } + return response(res, { data: null, success: true, diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 30ff4699a..5bd73626d 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, - resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility, - resourcePassword, - resourcePincode, + resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, resources } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, or } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -60,64 +60,53 @@ export async function getResourceAuthInfo( const isGuidInteger = /^\d+$/.test(resourceGuid); + const buildQuery = (whereClause: ReturnType) => + db + .select() + .from(resources) + .leftJoin( + resourcePolicies, + or( + eq( + resourcePolicies.resourcePolicyId, + resources.resourcePolicyId + ), + eq( + resourcePolicies.resourcePolicyId, + resources.defaultResourcePolicyId + ) + ) + ) + .leftJoin( + resourcePolicyPincode, + eq( + resourcePolicyPincode.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyPassword, + eq( + resourcePolicyPassword.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyHeaderAuth, + eq( + resourcePolicyHeaderAuth.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .where(whereClause) + .limit(1); + const [result] = isGuidInteger && build === "saas" - ? await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - - .leftJoin( - resourceHeaderAuth, - eq( - resourceHeaderAuth.resourceId, - resources.resourceId - ) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId - ) - ) - .where(eq(resources.resourceId, Number(resourceGuid))) - .limit(1) - : await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - - .leftJoin( - resourceHeaderAuth, - eq( - resourceHeaderAuth.resourceId, - resources.resourceId - ) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId - ) - ) - .where(eq(resources.resourceGuid, resourceGuid)) - .limit(1); + ? await buildQuery( + eq(resources.resourceId, Number(resourceGuid)) + ) + : await buildQuery(eq(resources.resourceGuid, resourceGuid)); const resource = result?.resources; if (!resource) { @@ -126,11 +115,10 @@ export async function getResourceAuthInfo( ); } - const pincode = result?.resourcePincode; - const password = result?.resourcePassword; - const headerAuth = result?.resourceHeaderAuth; - const headerAuthExtendedCompatibility = - result?.resourceHeaderAuthExtendedCompatibility; + const policy = result?.resourcePolicies; + const pincode = result?.resourcePolicyPincode; + const password = result?.resourcePolicyPassword; + const headerAuth = result?.resourcePolicyHeaderAuth; const url = resource.fullDomain ? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` @@ -146,13 +134,13 @@ export async function getResourceAuthInfo( pincode: pincode !== null, headerAuth: headerAuth !== null, headerAuthExtendedCompatibility: - headerAuthExtendedCompatibility !== null, - sso: resource.sso, + headerAuth?.extendedCompatibility ?? false, + sso: policy?.sso ?? false, blockAccess: resource.blockAccess, url: url ?? "", wildcard: resource.wildcard ?? false, fullDomain: resource.fullDomain, - whitelist: resource.emailWhitelistEnabled, + whitelist: policy?.emailWhitelistEnabled ?? false, skipToIdpId: resource.skipToIdpId, orgId: resource.orgId, postAuthPath: resource.postAuthPath ?? null diff --git a/server/routers/resource/getResourcePolicies.ts b/server/routers/resource/getResourcePolicies.ts new file mode 100644 index 000000000..6742a0bd5 --- /dev/null +++ b/server/routers/resource/getResourcePolicies.ts @@ -0,0 +1,109 @@ +import { db, resources } from "@server/db"; +import { + queryResourcePolicy, + type GetResourcePolicyResponse +} from "@server/routers/policy/getResourcePolicy"; +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"; + +const getResourcePoliciesParamsSchema = z.strictObject({ + resourceId: z.string().transform(Number).pipe(z.int().positive()) +}); + +export type GetResourcePoliciesResponse = { + defaultPolicy: GetResourcePolicyResponse; + sharedPolicy: GetResourcePolicyResponse | null; +}; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/policies", + description: "Get the inline and shared policies associated with a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Policy], + request: { + params: getResourcePoliciesParamsSchema + }, + responses: {} +}); + +export async function getResourcePolicies( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getResourcePoliciesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ + defaultResourcePolicyId: resources.defaultResourcePolicyId, + resourcePolicyId: resources.resourcePolicyId + }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + if (!resource.defaultResourcePolicyId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Resource has no default policy" + ) + ); + } + + const [defaultPolicy, sharedPolicy] = await Promise.all([ + queryResourcePolicy({ + resourcePolicyId: resource.defaultResourcePolicyId + }), + resource.resourcePolicyId + ? queryResourcePolicy({ + resourcePolicyId: resource.resourcePolicyId + }) + : null + ]); + + return response(res, { + data: { + defaultPolicy: + // the policy will always be non nullable + defaultPolicy as unknown as GetResourcePolicyResponse, + sharedPolicy + }, + success: true, + error: false, + message: "Resource policies 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/index.ts b/server/routers/resource/index.ts index d8ff4dba9..d8605ac5f 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -34,3 +34,4 @@ export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; export * from "./getStatusHistory"; export * from "./getBrowserTarget"; +export * from "./getResourcePolicies"; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 55241eb0d..f498757bc 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -7,6 +7,10 @@ import { resourceLabels, resourcePassword, resourcePincode, + resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, resources, roleResources, sites, @@ -189,39 +193,55 @@ function queryResourcesBase() { name: resources.name, ssl: resources.ssl, fullDomain: resources.fullDomain, - passwordId: resourcePassword.passwordId, - sso: resources.sso, - pincodeId: resourcePincode.pincodeId, - whitelist: resources.emailWhitelistEnabled, + passwordId: resourcePolicyPassword.passwordId, + sso: resourcePolicies.sso, + pincodeId: resourcePolicyPincode.pincodeId, + whitelist: resourcePolicies.emailWhitelistEnabled, proxyPort: resources.proxyPort, enabled: resources.enabled, domainId: resources.domainId, niceId: resources.niceId, wildcard: resources.wildcard, - headerAuthId: resourceHeaderAuth.headerAuthId, - headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, + mode: resources.mode, health: resources.health, - mode: resources.mode + headerAuthId: resourcePolicyHeaderAuth.headerAuthId, + headerAuthExtendedCompatibility: + resourcePolicyHeaderAuth.extendedCompatibility }) .from(resources) .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) + resourcePolicies, + or( + eq( + resourcePolicies.resourcePolicyId, + resources.resourcePolicyId + ), + eq( + resourcePolicies.resourcePolicyId, + resources.defaultResourcePolicyId + ) + ) ) + .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuth, - eq(resourceHeaderAuth.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuthExtendedCompatibility, + resourcePolicyPassword, eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resources.resourceId + resourcePolicyPassword.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyPincode, + eq( + resourcePolicyPincode.resourcePolicyId, + resourcePolicies.resourcePolicyId + ) + ) + .leftJoin( + resourcePolicyHeaderAuth, + eq( + resourcePolicyHeaderAuth.resourcePolicyId, + resourcePolicies.resourcePolicyId ) ) .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) @@ -231,10 +251,10 @@ function queryResourcesBase() { ) .groupBy( resources.resourceId, - resourcePassword.passwordId, - resourcePincode.pincodeId, - resourceHeaderAuth.headerAuthId, - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + resourcePolicies.resourcePolicyId, + resourcePolicyPassword.passwordId, + resourcePolicyPincode.pincodeId, + resourcePolicyHeaderAuth.headerAuthId ); } @@ -370,21 +390,21 @@ export async function listResources( case "protected": conditions.push( or( - eq(resources.sso, true), - eq(resources.emailWhitelistEnabled, true), - not(isNull(resourceHeaderAuth.headerAuthId)), - not(isNull(resourcePincode.pincodeId)), - not(isNull(resourcePassword.passwordId)) + eq(resourcePolicies.sso, true), + eq(resourcePolicies.emailWhitelistEnabled, true), + not(isNull(resourcePolicyHeaderAuth.headerAuthId)), + not(isNull(resourcePolicyPincode.pincodeId)), + not(isNull(resourcePolicyPassword.passwordId)) ) ); break; case "not_protected": conditions.push( - not(eq(resources.sso, true)), - not(eq(resources.emailWhitelistEnabled, true)), - isNull(resourceHeaderAuth.headerAuthId), - isNull(resourcePincode.pincodeId), - isNull(resourcePassword.passwordId) + not(eq(resourcePolicies.sso, true)), + not(eq(resourcePolicies.emailWhitelistEnabled, true)), + isNull(resourcePolicyHeaderAuth.headerAuthId), + isNull(resourcePolicyPincode.pincodeId), + isNull(resourcePolicyPassword.passwordId) ); break; } @@ -558,9 +578,9 @@ export async function listResources( ssl: row.ssl, fullDomain: row.fullDomain, passwordId: row.passwordId, - sso: row.sso, + sso: row.sso ?? false, pincodeId: row.pincodeId, - whitelist: row.whitelist, + whitelist: row.whitelist ?? false, proxyPort: row.proxyPort, wildcard: row.wildcard, mode: row.mode, diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts index 9dcdcd086..eee70bd35 100644 --- a/server/routers/resource/types.ts +++ b/server/routers/resource/types.ts @@ -1,3 +1,6 @@ +import type { Resource, ResourcePolicy } from "@server/db"; +import type { PaginatedResponse } from "@server/types/Pagination"; + export type GetMaintenanceInfoResponse = { resourceId: number; name: string; @@ -8,3 +11,19 @@ export type GetMaintenanceInfoResponse = { maintenanceMessage: string | null; maintenanceEstimatedTime: string | null; }; + +export type AttachedResource = Pick< + Resource, + "resourceId" | "name" | "fullDomain" +>; + +export type ResourcePolicyWithResources = Pick< + ResourcePolicy, + "resourcePolicyId" | "niceId" | "name" | "orgId" +> & { + resources: Array; +}; + +export type ListResourcePoliciesResponse = PaginatedResponse<{ + policies: Array; +}>; diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 17984eb2b..15653a87e 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -1,12 +1,23 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, domainNamespaces, loginPage } from "@server/db"; +import { + db, + domainNamespaces, + loginPage, + resourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility, + resourcePassword, + resourcePincode, + resourceRules, + resourceWhitelist +} from "@server/db"; import { domains, Org, orgDomains, orgs, Resource, + resourcePolicies, resources } from "@server/db"; import { eq, and, ne } from "drizzle-orm"; @@ -75,7 +86,8 @@ const updateHttpResourceBodySchema = z // SSH settings pamMode: z.enum(["passthrough", "push"]).optional(), authDaemonMode: z.enum(["site", "remote", "native"]).optional(), - authDaemonPort: z.int().min(1).max(65535).nullable().optional() + authDaemonPort: z.int().min(1).max(65535).nullable().optional(), + resourcePolicyId: z.number().nullable().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -172,7 +184,8 @@ const updateRawResourceBodySchema = z stickySession: z.boolean().optional(), enabled: z.boolean().optional(), proxyProtocol: z.boolean().optional(), - proxyProtocolVersion: z.int().min(1).optional() + proxyProtocolVersion: z.int().min(1).optional(), + resourcePolicyId: z.number().nullable().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -308,6 +321,42 @@ async function updateHttpResource( const updateData = parsedBody.data; + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.wildcardSubdomain + ); + + if (updateData.resourcePolicyId != null) { + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Resource policies are not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + + const [existingPolicy] = await db + .select() + .from(resourcePolicies) + .where( + eq( + resourcePolicies.resourcePolicyId, + updateData.resourcePolicyId + ) + ) + .limit(1); + + if (!existingPolicy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource policy with ID ${updateData.resourcePolicyId} not found` + ) + ); + } + } + if (updateData.niceId) { const [existingResource] = await db .select() @@ -333,10 +382,6 @@ async function updateHttpResource( // Wildcard subdomains are a paid feature if (updateData.subdomain && updateData.subdomain.includes("*")) { - const isLicensed = await isLicensedOrSubscribed( - resource.orgId, - tierMatrix.wildcardSubdomain - ); if (!isLicensed) { return next( createHttpError( @@ -481,10 +526,6 @@ async function updateHttpResource( headers = null; } - const isLicensed = await isLicensedOrSubscribed( - resource.orgId, - tierMatrix.maintencePage - ); if (!isLicensed) { updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeType = undefined; @@ -542,38 +583,122 @@ async function updateRawResource( } const updateData = parsedBody.data; + let updatedResource: Resource | null = null; - if (updateData.niceId) { - const [existingResource] = await db - .select() - .from(resources) - .where( - and( - eq(resources.niceId, updateData.niceId), - eq(resources.orgId, resource.orgId) - ) - ); - - if ( - existingResource && - existingResource.resourceId !== resource.resourceId - ) { - return next( - createHttpError( - HttpCode.CONFLICT, - `A resource with niceId "${updateData.niceId}" already exists` - ) - ); - } - } - - const updatedResource = await db - .update(resources) - .set(updateData) + const [existingResource] = await db + .select() + .from(resources) .where(eq(resources.resourceId, resource.resourceId)) - .returning(); + .limit(1); - if (updatedResource.length === 0) { + await db.transaction(async (trx) => { + if (updateData.resourcePolicyId != null) { + const [existingPolicy] = await trx + .select() + .from(resourcePolicies) + .where( + eq( + resourcePolicies.resourcePolicyId, + updateData.resourcePolicyId + ) + ) + .limit(1); + + if (!existingPolicy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource policy with ID ${updateData.resourcePolicyId} not found` + ) + ); + } + } else { + // we are in an inline policy and we need to clear out the old tables + await Promise.all([ + trx + .delete(resourcePassword) + .where( + eq( + resourcePassword.resourceId, + existingResource.resourceId + ) + ), + trx + .delete(resourcePincode) + .where( + eq( + resourcePincode.resourceId, + existingResource.resourceId + ) + ), + trx + .delete(resourceHeaderAuth) + .where( + eq( + resourceHeaderAuth.resourceId, + existingResource.resourceId + ) + ), + trx + .delete(resourceHeaderAuthExtendedCompatibility) + .where( + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + existingResource.resourceId + ) + ), + trx + .delete(resourceWhitelist) + .where( + eq( + resourceWhitelist.resourceId, + existingResource.resourceId + ) + ), + + trx + .delete(resourceRules) + .where( + eq( + resourceRules.resourceId, + existingResource.resourceId + ) + ) + ]); + } + + if (updateData.niceId) { + const [existingResourceConflict] = await trx + .select() + .from(resources) + .where( + and( + eq(resources.niceId, updateData.niceId), + eq(resources.orgId, resource.orgId) + ) + ); + + if ( + existingResourceConflict && + existingResourceConflict.resourceId !== resource.resourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A resource with niceId "${updateData.niceId}" already exists` + ) + ); + } + } + + [updatedResource] = await trx + .update(resources) + .set(updateData) + .where(eq(resources.resourceId, resource.resourceId)) + .returning(); + }); + + if (!updatedResource) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -583,7 +708,7 @@ async function updateRawResource( } return response(res, { - data: updatedResource[0], + data: updatedResource, success: true, error: false, message: "Non-http Resource updated successfully", diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 8b73a984b..307e74723 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -140,7 +140,7 @@ const listSitesSchema = z.object({ page: z.coerce .number() // for prettier formatting .int() - .min(0) + .positive() .optional() .catch(1) .default(1) diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index af900150b..528a6cabf 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -144,7 +144,7 @@ export async function getOrgUser( return next( createHttpError( HttpCode.FORBIDDEN, - "User does not have permission perform this action" + "User does not have permission to get organization user details" ) ); } diff --git a/src/app/[orgId]/settings/(private)/policies/layout.tsx b/src/app/[orgId]/settings/(private)/policies/layout.tsx new file mode 100644 index 000000000..ef5803e1a --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/layout.tsx @@ -0,0 +1,23 @@ +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import OrgProvider from "@app/providers/OrgProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import { redirect } from "next/navigation"; + +export interface PolicyLayoutPageProps { + params: Promise<{ orgId: string }>; + children: React.ReactNode; +} + +export default async function PolicyLayoutPage(props: PolicyLayoutPageProps) { + const params = await props.params; + + let org: GetOrgResponse | null = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings`); + } + + return {props.children}; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx new file mode 100644 index 000000000..5519506b9 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx @@ -0,0 +1,60 @@ +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +export interface EditPolicyPageProps { + params: Promise<{ niceId: string; orgId: string }>; +} + +export default async function EditPolicyPage(props: EditPolicyPageProps) { + const params = await props.params; + const t = await getTranslations(); + + let policyResponse: GetResourcePolicyResponse | null = null; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/resource-policy/${params.niceId}`, + await authCookieHeader() + ); + policyResponse = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/policies/resource`); + } + + if (!policyResponse) { + redirect(`/${params.orgId}/settings/policies/resource`); + } + + return ( + <> +
+ + + +
+ + + + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx new file mode 100644 index 000000000..edf67fbef --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx @@ -0,0 +1,35 @@ +import { CreatePolicyForm } from "@app/components/resource-policy/CreatePolicyForm"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; + +export interface CreateResourcePolicyPageProps { + params: Promise<{ orgId: string }>; +} + +export default async function CreateResourcePolicyPage( + props: CreateResourcePolicyPageProps +) { + const params = await props.params; + const t = await getTranslations(); + + return ( + <> +
+ + + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/(private)/policies/resource/page.tsx b/src/app/[orgId]/settings/(private)/policies/resource/page.tsx new file mode 100644 index 000000000..a51bbef3a --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resource/page.tsx @@ -0,0 +1,68 @@ +import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import type { GetOrgResponse } from "@server/routers/org"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +export interface ResourcePoliciesPageProps { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +} + +export default async function ResourcePoliciesPage( + props: ResourcePoliciesPageProps +) { + const params = await props.params; + const t = await getTranslations(); + const searchParams = new URLSearchParams(await props.searchParams); + + let org: GetOrgResponse | null = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + let policies: ListResourcePoliciesResponse["policies"] = []; + let pagination: ListResourcePoliciesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/resource-policies?${searchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + policies = responseData.policies; + pagination = responseData.pagination; + } catch (e) {} + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 8ee7b1dc0..6a3d648a4 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -13,6 +13,7 @@ import { Layout } from "@app/components/Layout"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; import { orgNavSections } from "@app/app/navigation"; +import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; export const dynamic = "force-dynamic"; @@ -48,13 +49,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const t = await getTranslations(); try { - const getOrgUser = cache(() => - internal.get>( - `/org/${params.orgId}/user/${user.userId}`, - cookie - ) - ); - const orgUser = await getOrgUser(); + const orgUser = await getCachedOrgUser(params.orgId, user.userId); if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) { throw new Error(t("userErrorNotAdminOrOwner")); diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 0c2410e49..61bf29b00 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -169,8 +169,6 @@ export default function GeneralPage() { router.replace(`?${params.toString()}`, { scroll: false }); }; -<<<<<<< HEAD -======= const queryDateTime = async ( startDate: DateTimeValue, endDate: DateTimeValue, @@ -276,7 +274,6 @@ export default function GeneralPage() { } }; ->>>>>>> main const exportData = async () => { try { const params: any = { diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index d1b9c4cae..a946af736 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -143,9 +143,7 @@ export default function ConnectionLogsPage() { enabled: isPaidUser(tierMatrix.connectionLogs) && build !== "oss" }); - const rows = isLoading - ? generateSampleConnectionLogs() - : (data?.log ?? []); + const rows = isLoading ? generateSampleConnectionLogs() : (data?.log ?? []); const totalCount = data?.pagination?.total ?? 0; const filterAttributes = data?.filterAttributes ?? { protocols: [], @@ -205,8 +203,6 @@ export default function ConnectionLogsPage() { router.replace(`?${params.toString()}`, { scroll: false }); }; -<<<<<<< HEAD -======= const queryDateTime = async ( startDate: DateTimeValue, endDate: DateTimeValue, @@ -311,7 +307,6 @@ export default function ConnectionLogsPage() { } }; ->>>>>>> main const exportData = async () => { try { const params: any = { @@ -729,10 +724,11 @@ function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] userId: i % 2 === 0 ? `user-${i}` : null, sourceAddr: `192.168.1.${i + 1}:${40000 + i}`, destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)], - protocol: - protocols[Math.floor(Math.random() * protocols.length)], + protocol: protocols[Math.floor(Math.random() * protocols.length)], startedAt, - endedAt: active ? null : startedAt + Math.floor(Math.random() * 3600), + endedAt: active + ? null + : startedAt + Math.floor(Math.random() * 3600), bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024), bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024), resourceName: `Resource ${(i % 3) + 1}`, diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 3a184a511..760205ec8 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -1,958 +1,351 @@ "use client"; -import { RolesSelector } from "@app/components/roles-selector"; -import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; -import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; +import ActionBanner from "@app/components/ActionBanner"; +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, - SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { + StrategySelect, + type StrategyOption +} from "@app/components/StrategySelect"; import { Button } from "@app/components/ui/button"; import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { InfoPopup } from "@app/components/ui/info-popup"; + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { UsersSelector } from "@app/components/users-selector"; -import type { ResourceContextType } from "@app/contexts/resourceContext"; + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { cn } from "@app/lib/cn"; import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; import { zodResolver } from "@hookform/resolvers/zod"; +import { CaretSortIcon } from "@radix-ui/react-icons"; import { build } from "@server/build"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { UserType } from "@server/types/UserTypes"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm"; import { Binary, Bot, InfoIcon, Key } from "lucide-react"; +import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { useRouter } from "next/navigation"; -import { - useActionState, - useEffect, - useMemo, - useRef, - useState, - useTransition -} from "react"; -import { useForm } from "react-hook-form"; +import { useEffect, useState, useTransition } from "react"; +import { useForm, useWatch } from "react-hook-form"; import { z } from "zod"; -const UsersRolesFormSchema = z.object({ - roles: z.array( +const resourceTypeSchema = z + .object({ + type: z.literal("inline") + }) + .or( z.object({ - id: z.string(), - text: z.string() + type: z.literal("shared"), + resourcePolicyId: z.number() }) - ), - users: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ) -}); + ); -const whitelistSchema = z.object({ - emails: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ) -}); +type ResourcePolicyType = StrategyOption<"inline" | "shared">; export default function ResourceAuthenticationPage() { const { org } = useOrgContext(); - const { resource, updateResource, authInfo, updateAuthInfo } = - useResourceContext(); + const { resource, updateResource } = useResourceContext(); + const queryClient = useQueryClient(); const { env } = useEnvContext(); + const { isPaidUser } = usePaidStatus(); const api = createApiClient({ env }); const router = useRouter(); const t = useTranslations(); - const { isPaidUser } = usePaidStatus(); - - const queryClient = useQueryClient(); - const { data: resourceRoles = [], isLoading: isLoadingResourceRoles } = - useQuery( - resourceQueries.resourceRoles({ - resourceId: resource.resourceId - }) - ); - const { data: resourceUsers = [], isLoading: isLoadingResourceUsers } = - useQuery( - resourceQueries.resourceUsers({ - resourceId: resource.resourceId - }) - ); - - const { data: whitelist = [], isLoading: isLoadingWhiteList } = useQuery( - resourceQueries.resourceWhitelist({ + const { data: policies, isLoading: isLoadingPolicies } = useQuery( + resourceQueries.policies({ resourceId: resource.resourceId }) ); - 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({ + const form = useForm({ + resolver: zodResolver(resourceTypeSchema), + defaultValues: { + type: + build !== "oss" && resource.resourcePolicyId + ? "shared" + : "inline" + } + }); + + const selectedResourceType = useWatch({ + control: form.control, + name: "type" + }); + + const [resourcePolicysearchQuery, setResourcePolicySearchQuery] = + useState(""); + + const { data: policiesList = [] } = useQuery({ + ...orgQueries.policies({ orgId: org.org.orgId, - useOrgOnlyIdp: env.app.identityProviderMode === "org" - }) + name: resourcePolicysearchQuery + }), + enabled: selectedResourceType === "shared" }); - const pageLoading = - isLoadingOrgRoles || - isLoadingOrgUsers || - isLoadingResourceRoles || - isLoadingResourceUsers || - isLoadingWhiteList || - isLoadingOrgIdps; + const [selectedPolicy, setSelectedPolicy] = useState<{ + name: string; + id: number; + } | null>(null); - const allRoles = useMemo(() => { - return orgRoles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin"); - }, [orgRoles]); - - const allUsers = useMemo(() => { - return 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 - })); + const resourcePolicyTypes: Array = [ + { + id: "inline", + title: t("resourcePolicyInline"), + description: t("resourcePolicyInlineDescription") + }, + { + id: "shared", + title: t("resourcePolicyShared"), + description: t("resourcePolicySharedDescription") } - return []; - }, [orgIdps]); - - const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false); + ]; useEffect(() => { - setSsoEnabled(resource.sso ?? false); - }, [resource.sso]); + if (!isLoadingPolicies && policies?.sharedPolicy) { + setSelectedPolicy({ + id: policies?.sharedPolicy.resourcePolicyId, + name: policies?.sharedPolicy.name + }); + } + }, [isLoadingPolicies, policies?.sharedPolicy]); - const [selectedIdpId, setSelectedIdpId] = useState( - resource.skipToIdpId || null - ); - - const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] = - useState(false); - const [loadingRemoveResourcePincode, setLoadingRemoveResourcePincode] = - useState(false); - const [ - loadingRemoveResourceHeaderAuth, - setLoadingRemoveResourceHeaderAuth - ] = useState(false); - - const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); - const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); - const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); - - const usersRolesForm = useForm({ - resolver: zodResolver(UsersRolesFormSchema), - defaultValues: { roles: [], users: [] } - }); - - const whitelistForm = useForm({ - resolver: zodResolver(whitelistSchema), - defaultValues: { emails: [] } - }); - - const hasInitializedRef = useRef(false); - - useEffect(() => { - if (pageLoading || hasInitializedRef.current) return; - - usersRolesForm.setValue( - "roles", - resourceRoles - .map((i) => ({ - id: i.roleId.toString(), - text: i.name - })) - .filter((role) => role.text !== "Admin") - ); - usersRolesForm.setValue( - "users", - resourceUsers.map((i) => ({ - id: i.userId.toString(), - text: `${getUserDisplayName({ - email: i.email, - username: i.username - })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` - })) - ); - - whitelistForm.setValue( - "emails", - whitelist.map((w) => ({ - id: w.email, - text: w.email - })) - ); - hasInitializedRef.current = true; - }, [pageLoading, resourceRoles, resourceUsers, whitelist, orgIdps]); - - const [, submitUserRolesForm, loadingSaveUsersRoles] = useActionState( - onSubmitUsersRoles, - null - ); - - async function onSubmitUsersRoles() { - const isValid = usersRolesForm.trigger(); - if (!isValid) return; - - const data = usersRolesForm.getValues(); + const [isUpdatingResource, startTransition] = useTransition(); + async function handleSaveResourcePolicyType() { try { - const jobs = [ - api.post(`/resource/${resource.resourceId}/roles`, { - roleIds: data.roles.map((i) => parseInt(i.id)) - }), - api.post(`/resource/${resource.resourceId}/users`, { - userIds: data.users.map((i) => i.id) - }), - api.post(`/resource/${resource.resourceId}`, { - sso: ssoEnabled, - skipToIdpId: selectedIdpId - }) - ]; - - await Promise.all(jobs); - - updateResource({ - sso: ssoEnabled, - skipToIdpId: selectedIdpId - }); - - updateAuthInfo({ - sso: ssoEnabled - }); - - toast({ - title: t("resourceAuthSettingsSave"), - description: t("resourceAuthSettingsSaveDescription") - }); - // invalidate resource queries - await queryClient.invalidateQueries( - resourceQueries.resourceUsers({ - resourceId: resource.resourceId - }) - ); - await queryClient.invalidateQueries( - resourceQueries.resourceRoles({ - resourceId: resource.resourceId - }) - ); - + if (selectedResourceType === "inline") { + await api.post(`/resource/${resource.resourceId}`, { + resourcePolicyId: null + }); + } else { + if (!selectedPolicy) { + toast({ + title: t("error"), + description: t("resourcePolicySelectError"), + variant: "destructive" + }); + return; + } + await api.post(`/resource/${resource.resourceId}`, { + resourcePolicyId: selectedPolicy.id + }); + } router.refresh(); - } catch (e) { - console.error(e); toast({ - variant: "destructive", - title: t("resourceErrorUsersRolesSave"), - description: formatAxiosError( - e, - t("resourceErrorUsersRolesSaveDescription") - ) + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") }); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + await queryClient.invalidateQueries( + resourceQueries.policies({ + resourceId: resource.resourceId + }) + ); } } - function removeResourcePassword() { - setLoadingRemoveResourcePassword(true); - - api.post(`/resource/${resource.resourceId}/password`, { - password: null - }) - .then(() => { - toast({ - title: t("resourcePasswordRemove"), - description: t("resourcePasswordRemoveDescription") - }); - - updateAuthInfo({ - password: false - }); - router.refresh(); - }) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorPasswordRemove"), - description: formatAxiosError( - e, - t("resourceErrorPasswordRemoveDescription") - ) - }); - }) - .finally(() => setLoadingRemoveResourcePassword(false)); - } - - function removeResourcePincode() { - setLoadingRemoveResourcePincode(true); - - api.post(`/resource/${resource.resourceId}/pincode`, { - pincode: null - }) - .then(() => { - toast({ - title: t("resourcePincodeRemove"), - description: t("resourcePincodeRemoveDescription") - }); - - updateAuthInfo({ - pincode: false - }); - router.refresh(); - }) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorPincodeRemove"), - description: formatAxiosError( - e, - t("resourceErrorPincodeRemoveDescription") - ) - }); - }) - .finally(() => setLoadingRemoveResourcePincode(false)); - } - - function removeResourceHeaderAuth() { - setLoadingRemoveResourceHeaderAuth(true); - - api.post(`/resource/${resource.resourceId}/header-auth`, { - user: null, - password: null, - extendedCompatibility: null - }) - .then(() => { - toast({ - title: t("resourceHeaderAuthRemove"), - description: t("resourceHeaderAuthRemoveDescription") - }); - - updateAuthInfo({ - headerAuth: false - }); - router.refresh(); - }) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorHeaderAuthRemove"), - description: formatAxiosError( - e, - t("resourceErrorHeaderAuthRemoveDescription") - ) - }); - }) - .finally(() => setLoadingRemoveResourceHeaderAuth(false)); - } + const pageLoading = isLoadingPolicies || !policies; if (pageLoading) { return <>; } + console.log({ + shared: policies.sharedPolicy + }); + return ( <> - {isSetPasswordOpen && ( - { - setIsSetPasswordOpen(false); - updateAuthInfo({ - password: true - }); - }} - /> - )} - - {isSetPincodeOpen && ( - { - setIsSetPincodeOpen(false); - updateAuthInfo({ - pincode: true - }); - }} - /> - )} - - {isSetHeaderAuthOpen && ( - { - setIsSetHeaderAuthOpen(false); - updateAuthInfo({ - headerAuth: true - }); - }} - /> - )} - - - - - {t("resourceUsersRoles")} - - - {t("resourceUsersRolesDescription")} - - - - - setSsoEnabled(val)} - /> - -
- - {ssoEnabled && ( - <> - ( - - - {t("roles")} - - - { - usersRolesForm.setValue( - "roles", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - /> - - - - {t( - "resourceRoleDescription" - )} - - - )} - /> - ( - - - {t("users")} - - - { - usersRolesForm.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - /> - - - - )} - /> - - )} - - {ssoEnabled && allIdps.length > 0 && ( -
- - -

- {t( - "defaultIdentityProviderDescription" - )} -

-
- )} - - -
-
- - + +
+ )} + + {selectedResourceType === "inline" ? ( + + + + ) : ( + policies.sharedPolicy && ( + - {t("resourceUsersRolesSubmit")} - - - - - - - - {t("resourceAuthMethods")} - - - {t("resourceAuthMethodsDescriptions")} - - - - - {/* Password Protection */} -
-
- - - {t("resourcePasswordProtection", { - status: authInfo.password - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* PIN Code Protection */} -
-
- - - {t("resourcePincodeProtection", { - status: authInfo.pincode - ? t("enabled") - : t("disabled") - })} - -
- -
- - {/* Header Authentication Protection */} -
-
- - - {authInfo.headerAuth - ? t( - "resourceHeaderAuthProtectionEnabled" - ) - : t( - "resourceHeaderAuthProtectionDisabled" - )} - -
- -
-
-
-
- - + + } + description={t( + "resourcePolicyReadOnlyDescription" + )} + actions={ + + } + /> + +
+ ) + )}
); } - -type OneTimePasswordFormSectionProps = Pick< - ResourceContextType, - "resource" | "updateResource" -> & { - whitelist: Array<{ email: string }>; - isLoadingWhiteList: boolean; -}; - -function OneTimePasswordFormSection({ - resource, - updateResource, - whitelist, - isLoadingWhiteList -}: OneTimePasswordFormSectionProps) { - const { env } = useEnvContext(); - const [whitelistEnabled, setWhitelistEnabled] = useState( - resource.emailWhitelistEnabled ?? false - ); - - useEffect(() => { - setWhitelistEnabled(resource.emailWhitelistEnabled); - }, [resource.emailWhitelistEnabled]); - - const queryClient = useQueryClient(); - - const [loadingSaveWhitelist, startTransition] = useTransition(); - const whitelistForm = useForm({ - resolver: zodResolver(whitelistSchema), - defaultValues: { emails: [] } - }); - const api = createApiClient({ env }); - const router = useRouter(); - const t = useTranslations(); - - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); - - useEffect(() => { - if (isLoadingWhiteList) return; - - whitelistForm.setValue( - "emails", - whitelist.map((w) => ({ - id: w.email, - text: w.email - })) - ); - }, [isLoadingWhiteList, whitelist, whitelistForm]); - - async function saveWhitelist() { - try { - await api.post(`/resource/${resource.resourceId}`, { - emailWhitelistEnabled: whitelistEnabled - }); - - if (whitelistEnabled) { - await api.post(`/resource/${resource.resourceId}/whitelist`, { - emails: whitelistForm.getValues().emails.map((i) => i.text) - }); - } - - updateResource({ - emailWhitelistEnabled: whitelistEnabled - }); - - toast({ - title: t("resourceWhitelistSave"), - description: t("resourceWhitelistSaveDescription") - }); - router.refresh(); - await queryClient.invalidateQueries( - resourceQueries.resourceWhitelist({ - resourceId: resource.resourceId - }) - ); - } catch (e) { - console.error(e); - toast({ - variant: "destructive", - title: t("resourceErrorWhitelistSave"), - description: formatAxiosError( - e, - t("resourceErrorWhitelistSaveDescription") - ) - }); - } - } - - return ( - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!env.email.emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - - - {whitelistEnabled && env.email.emailEnabled && ( -
- - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: - t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse(tag) - .success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t( - "otpEmailEnter" - )} - tags={ - whitelistForm.getValues() - .emails - } - setTags={(newRoles) => { - whitelistForm.setValue( - "emails", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={false} - sortTags={true} - /> - - - {t("otpEmailEnterDescription")} - - - )} - /> - - - )} -
-
- - - -
- ); -} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx index 5008c15c3..56ddb1460 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/layout.tsx @@ -96,10 +96,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { title: t("authentication"), href: `/{orgId}/settings/resources/proxy/{niceId}/authentication` }); - navItems.push({ - title: t("rules"), - href: `/{orgId}/settings/resources/proxy/{niceId}/rules` - }); + // navItems.push({ + // title: t("rules"), + // href: `/{orgId}/settings/resources/proxy/{niceId}/rules` + // }); } return ( diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 7c74be8b7..f40418afa 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -114,7 +114,13 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { toASCII } from "punycode"; -import { useEffect, useMemo, useState, useCallback } from "react"; +import { + useMemo, + useState, + useCallback, + useTransition, + useEffect +} from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { cn } from "@app/lib/cn"; @@ -226,7 +232,7 @@ export default function Page() { >([]); const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas"); - const [createLoading, setCreateLoading] = useState(false); + const [createLoading, startTransition] = useTransition(); const [showSnippets, setShowSnippets] = useState(false); const [niceId, setNiceId] = useState(""); @@ -386,8 +392,6 @@ export default function Page() { }; async function onSubmit() { - setCreateLoading(true); - const baseData = baseForm.getValues(); try { @@ -628,8 +632,6 @@ export default function Page() { ) }); } - - setCreateLoading(false); } // SSH strategy options diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 6d8bffee5..57e10a5c9 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -11,6 +11,7 @@ import { CreditCard, Fingerprint, Globe, + GlobeIcon, GlobeLock, KeyRound, Laptop, @@ -22,6 +23,7 @@ import { ScanEye, Server, Settings, + ShieldIcon, SquareMousePointer, TagIcon, TicketCheck, @@ -135,6 +137,24 @@ export const orgNavSections = ( } ] }, + ...(build !== "oss" + ? [ + { + title: "sidebarPolicies", + + icon: , + items: [ + { + title: "sidebarResourcePolicies", + href: "/{orgId}/settings/policies/resource", + icon: ( + + ) + } + ] + } + ] + : []), // PaidFeaturesAlert ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || build === "saas" || diff --git a/src/components/AuthPageBrandingForm.tsx b/src/components/AuthPageBrandingForm.tsx index 8bc4cc2ca..b4dc5c521 100644 --- a/src/components/AuthPageBrandingForm.tsx +++ b/src/components/AuthPageBrandingForm.tsx @@ -28,15 +28,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { build } from "@server/build"; +import { validateLocalPath } from "@app/lib/validateLocalPath"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; import { XIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { validateLocalPath } from "@app/lib/validateLocalPath"; -import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; export type AuthPageCustomizationProps = { orgId: string; diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 68ef31e85..9a327b602 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -191,22 +191,17 @@ export default function ProxyResourcesTable({ }); }; - const deleteResource = (resourceId: number) => { - api.delete(`/resource/${resourceId}`) - .catch((e) => { - console.error(t("resourceErrorDelte"), e); - toast({ - variant: "destructive", - title: t("resourceErrorDelte"), - description: formatAxiosError(e, t("resourceErrorDelte")) - }); - }) - .then(() => { - startTransition(() => { - router.refresh(); - setIsDeleteModalOpen(false); - }); + const deleteResource = async (resourceId: number) => { + await api.delete(`/resource/${resourceId}`).catch((e) => { + console.error(t("resourceErrorDelte"), e); + toast({ + variant: "destructive", + title: t("resourceErrorDelte"), + description: formatAxiosError(e, t("resourceErrorDelte")) }); + }); + router.refresh(); + setIsDeleteModalOpen(false); }; async function toggleResourceEnabled(val: boolean, resourceId: number) { @@ -727,7 +722,11 @@ export default function ProxyResourcesTable({ } buttonText={t("resourceDeleteConfirm")} - onConfirm={async () => deleteResource(selectedResource!.id)} + onConfirm={async () => + startTransition(() => + deleteResource(selectedResource!.id) + ) + } string={selectedResource.name} title={t("resourceDelete")} /> diff --git a/src/components/ResourcePoliciesTable.tsx b/src/components/ResourcePoliciesTable.tsx new file mode 100644 index 000000000..3039c821c --- /dev/null +++ b/src/components/ResourcePoliciesTable.tsx @@ -0,0 +1,311 @@ +"use client"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { + AttachedResource, + ListResourcePoliciesResponse +} from "@server/routers/resource/types"; +import type { PaginationState } from "@tanstack/react-table"; +import { + ArrowRight, + ChevronDown, + MoreHorizontal, + Waypoints +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, 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, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "./ui/dropdown-menu"; +import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; +import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; + +type ResourcePolicyRow = ListResourcePoliciesResponse["policies"][number]; + +export type ResourcePoliciesTableProps = { + policies: Array; + orgId: string; + pagination: PaginationState; + rowCount: number; +}; + +export function ResourcePoliciesTable({ + policies, + orgId, + pagination, + rowCount +}: ResourcePoliciesTableProps) { + const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const t = useTranslations(); + + const { env } = useEnvContext(); + + const api = createApiClient({ env }); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedResourcePolicy, setSelectedResourcePolicy] = + useState(null); + + const deleteResourcePolicy = async (resourcePolicyId: number) => { + await api + .delete(`/resource-policy/${resourcePolicyId}`) + .catch((e) => { + console.error(t("resourceErrorDelte"), e); + toast({ + variant: "destructive", + title: t("resourceErrorDelte"), + description: formatAxiosError(e, t("resourceErrorDelte")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; + + const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); + + const refreshData = () => { + startTransition(() => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + }; + + function ResourceListCell({ + resources + }: { + resources?: AttachedResource[]; + }) { + if (!resources || resources.length === 0) { + return ( +
+ + + {t("resourcePoliciesAttachedResourcesEmpty")} + +
+ ); + } + + return ( + + + + + + {resources.map((resource) => ( + +
+ {resource.name} +
+ + {resource.fullDomain} + +
+ ))} +
+
+ ); + } + + const proxyColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: () => {t("name")}, + cell: ({ row }) => {row.original.name} + }, + { + id: "niceId", + accessorKey: "nice", + friendlyName: t("identifier"), + enableHiding: true, + header: () => {t("identifier")}, + cell: ({ row }) => { + return {row.original.niceId || "-"}; + } + }, + { + id: "resources", + accessorKey: "resources", + friendlyName: t("resourcePoliciesAttachedResourcesColumnTitle"), + header: () => ( + + {t("resourcePoliciesAttachedResourcesColumnTitle")} + + ), + cell: ({ row }) => { + return ; + } + }, + { + id: "actions", + enableHiding: false, + header: () => , + cell: ({ row }) => { + const policyRow = row.original; + return ( +
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedResourcePolicy(policyRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ); + } + } + ]; + + const handlePaginationChange = (newPage: PaginationState) => { + searchParams.set("page", (newPage.pageIndex + 1).toString()); + searchParams.set("pageSize", newPage.pageSize.toString()); + filter({ + searchParams + }); + }; + + const handleSearchChange = useDebouncedCallback((query: string) => { + searchParams.set("query", query); + searchParams.delete("page"); + filter({ + searchParams + }); + }, 300); + + return ( + <> + + {selectedResourcePolicy && ( + { + setIsDeleteModalOpen(val); + setSelectedResourcePolicy(null); + }} + dialog={ +
+

{t("resourcePolicyQuestionRemove")}

+

{t("resourcePolicyMessageRemove")}

+
+ } + buttonText={t("resourcePolicyDeleteConfirm")} + onConfirm={async () => + deleteResourcePolicy( + selectedResourcePolicy.resourcePolicyId + ) + } + string={selectedResourcePolicy.name} + title={t("resourcePolicyDelete")} + /> + )} + + startNavigation(() => + router.push( + `/${orgId}/settings/policies/resource/create` + ) + ) + } + addButtonText={t("resourcePoliciesAdd")} + onRefresh={refreshData} + isRefreshing={isRefreshing || isFiltering} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility + columnVisibility={{ niceId: false }} + stickyLeftColumn="name" + stickyRightColumn="actions" + /> + + ); +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 87c7c2db3..19d5fe7b9 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -72,12 +72,19 @@ export function SettingsSectionBody({ } export function SettingsSectionFooter({ - children + children, + className }: { children: React.ReactNode; + className?: string; }) { return ( -
+
{children}
); diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index 7f747360f..b4cd961d4 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -25,11 +25,15 @@ export function StrategySelect({ value: controlledValue, defaultValue, onChange, - cols + cols = 1 }: StrategySelectProps) { - const [uncontrolledSelected, setUncontrolledSelected] = useState(defaultValue); + const [uncontrolledSelected, setUncontrolledSelected] = useState< + TValue | undefined + >(defaultValue); const isControlled = controlledValue !== undefined; - const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected; + const selected = isControlled + ? (controlledValue ?? undefined) + : uncontrolledSelected; return ( ({ if (!isControlled) setUncontrolledSelected(typedValue); onChange?.(typedValue); }} - className={`grid md:grid-cols-${cols ? cols : 1} gap-4`} + style={{ + // @ts-expect-error + "--cols": `repeat(${cols}, 1fr)` + }} + className="grid md:grid-cols-(--cols) gap-4" > {options.map((option: StrategyOption) => (
- + ); diff --git a/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx new file mode 100644 index 000000000..30a87ffc8 --- /dev/null +++ b/src/components/resource-policy/CreatePolicyAuthMethodsSectionForm.tsx @@ -0,0 +1,521 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { createPolicySchema, type PolicyFormValues } from "."; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; + +import { cn } from "@app/lib/cn"; +import { Binary, Bot, Key, Plus } from "lucide-react"; + +import { useEffect, useState } from "react"; +import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; + +// ─── CreatePolicyAuthMethodsSectionForm ─────────────────────────────────────── + +const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); + +export type CreatePolicyAuthMethodsSectionFormProps = { + form: UseFormReturn; +}; + +export function CreatePolicyAuthMethodsSectionForm({ + form: parentForm +}: CreatePolicyAuthMethodsSectionFormProps) { + const t = useTranslations(); + const [isExpanded, setIsExpanded] = useState(false); + const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); + const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); + const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + password: true, + pincode: true, + headerAuth: true + }) + ), + defaultValues: { + password: null, + pincode: null, + headerAuth: null + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue("password", values.password as any); + parentForm.setValue("pincode", values.pincode as any); + parentForm.setValue("headerAuth", values.headerAuth as any); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + + const password = useWatch({ + control: form.control, + name: "password" + }); + const pincode = useWatch({ + control: form.control, + name: "pincode" + }); + const headerAuth = useWatch({ + control: form.control, + name: "headerAuth" + }); + + const passwordForm = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: "" } + }); + + const pincodeForm = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: "" } + }); + + const headerAuthForm = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { user: "", password: "", extendedCompatibility: true } + }); + + if (!isExpanded) { + return ( + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + + + ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + ); +} diff --git a/src/components/resource-policy/CreatePolicyForm.tsx b/src/components/resource-policy/CreatePolicyForm.tsx new file mode 100644 index 000000000..30fd7919d --- /dev/null +++ b/src/components/resource-policy/CreatePolicyForm.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +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 { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +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"; +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 { useMemo, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { CreatePolicyUsersRolesSectionForm } from "./CreatePolicyUserRolesSectionForm"; +import { CreatePolicyAuthMethodsSectionForm } from "./CreatePolicyAuthMethodsSectionForm"; +import { CreatePolicyOtpEmailSectionForm } from "./CreatePolicyOtpEmailSectionForm"; +import { CreatePolicyRulesSectionForm } from "./CreatePolicyRulesSectionForm"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; + +// ─── CreatePolicyForm ───────────────────────────────────────────────────────── + +export type CreatePolicyFormProps = {}; + +export function CreatePolicyForm({}: CreatePolicyFormProps) { + const { org } = useOrgContext(); + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [isSubmitting, startTransition] = useTransition(); + 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, + // access control + sso: payload.sso, + roleIds: payload.roles.map((r) => r.id), + userIds: payload.users.map((u) => u.id), + skipToIdpId: payload.skipToIdpId, + // auth methods + password: payload.password?.password, + pincode: payload.pincode?.pincode, + headerAuth: payload.headerAuth, + // email OTP + emailWhitelistEnabled: payload.emailWhitelistEnabled, + emails: payload.emails.map((email) => email.text), + // rules + applyRules: payload.applyRules, + rules: payload.rules + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorCreate"), + description: formatAxiosError( + e, + t("policyErrorCreateDescription") + ) + }); + }); + + if (res && res.status === 201) { + const niceId = res.data.data.niceId; + router.push( + `/${org.org.orgId}/settings/policies/resource/${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 <>; + } + + const policyTiers = tierMatrix[TierFeature.ResourcePolicies]; + const isDisabled = !isPaidUser(policyTiers); + + return ( + <> + +
+
+ + {/* Name */} + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + + {t("name")} + + + + + + + )} + /> + + + + + + + + + +
+ +
+ +
+
+ + ); +} diff --git a/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx new file mode 100644 index 000000000..fb324cced --- /dev/null +++ b/src/components/resource-policy/CreatePolicyOtpEmailSectionForm.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { createPolicySchema, type PolicyFormValues } from "."; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; + +import { InfoIcon, Plus } from "lucide-react"; + +import { useEffect, useState } from "react"; +import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; + +// ─── CreatePolicyOtpEmailSectionForm ────────────────────────────────────────── + +export type CreatePolicyOtpEmailSectionFormProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +export function CreatePolicyOtpEmailSectionForm({ + form: parentForm, + emailEnabled +}: CreatePolicyOtpEmailSectionFormProps) { + const t = useTranslations(); + const [isExpanded, setIsExpanded] = useState(false); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + emailWhitelistEnabled: true, + emails: true + }) + ), + defaultValues: { + emailWhitelistEnabled: false, + emails: [] + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue( + "emailWhitelistEnabled", + values.emailWhitelistEnabled as boolean + ); + parentForm.setValue("emails", values.emails as [Tag, ...Tag[]]); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + + const whitelistEnabled = useWatch({ + control: form.control, + name: "emailWhitelistEnabled" + }); + + if (!isExpanded) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + + + ); + } + + return ( +
+ + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + +
+ ); +} diff --git a/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx new file mode 100644 index 000000000..4550d5bfb --- /dev/null +++ b/src/components/resource-policy/CreatePolicyRulesSectionForm.tsx @@ -0,0 +1,1092 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { createPolicySchema, type PolicyFormValues } from "."; +import { toast } from "@app/hooks/useToast"; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; + +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; + +// ─── CreatePolicyRulesSectionForm ───────────────────────────────────────────── + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + +export type CreatePolicyRulesSectionFormProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export function CreatePolicyRulesSectionForm({ + form: parentForm, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: CreatePolicyRulesSectionFormProps) { + const t = useTranslations(); + const [isExpanded, setIsExpanded] = useState(false); + const [rules, setRules] = useState([]); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + applyRules: true, + rules: true + }) + ), + defaultValues: { + applyRules: false, + rules: [] + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue("applyRules", values.applyRules as boolean); + parentForm.setValue("rules", values.rules as any); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + + const rulesEnabled = useWatch({ + control: form.control, + name: "applyRules" + }); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "PATH", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, syncFormRules] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + if (!isExpanded) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + + + + ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx b/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx new file mode 100644 index 000000000..132363fc1 --- /dev/null +++ b/src/components/resource-policy/CreatePolicyUserRolesSectionForm.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { createPolicySchema, type PolicyFormValues } from "."; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { type UseFormReturn, useForm, useWatch } from "react-hook-form"; + +// ─── CreatePolicyUsersRolesSectionForm ──────────────────────────────────────── + +export type CreatePolicyUsersRolesSectionFormProps = { + form: UseFormReturn; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + allIdps: { id: number; text: string }[]; +}; + +export function CreatePolicyUsersRolesSectionForm({ + form: parentForm, + allRoles, + allUsers, + allIdps +}: CreatePolicyUsersRolesSectionFormProps) { + const t = useTranslations(); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + sso: true, + skipToIdpId: true, + roles: true, + users: true + }) + ), + defaultValues: { + sso: true, + skipToIdpId: null, + roles: [], + users: [] + } + }); + + useEffect(() => { + const subscription = form.watch((values) => { + parentForm.setValue("sso", values.sso as boolean); + parentForm.setValue("skipToIdpId", values.skipToIdpId as number | null); + parentForm.setValue("roles", values.roles as [Tag, ...Tag[]]); + parentForm.setValue("users", values.users as [Tag, ...Tag[]]); + }); + return () => subscription.unsubscribe(); + }, [form, parentForm]); + + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); + const selectedIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + + return ( +
+ + + + {t("resourceUsersRoles")} + + + {t("resourcePolicyUsersRolesDescription")} + + + + + { + form.setValue("sso", val); + }} + /> + + {ssoEnabled && ( + <> + ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t("defaultIdentityProviderDescription")} +

+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx new file mode 100644 index 000000000..034a1ca78 --- /dev/null +++ b/src/components/resource-policy/EditPolicyAuthMethodsSectionForm.tsx @@ -0,0 +1,671 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useRouter } from "next/navigation"; +import { createPolicySchema } from "."; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { SwitchInput } from "@app/components/SwitchInput"; +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 { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; + +import { Binary, Bot, Key, Plus } from "lucide-react"; + +import { cn } from "@app/lib/cn"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { useActionState, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import type { AxiosResponse } from "axios"; + +// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── + +const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); + +export function EditPolicyAuthMethodsSectionForm({ + readonly +}: { + readonly?: boolean; +}) { + const { policy } = useResourcePolicyContext(); + const router = useRouter(); + + const api = createApiClient(useEnvContext()); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + password: true, + pincode: true, + headerAuth: true + }) + ) + }); + + const t = useTranslations(); + const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); + const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); + const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); + + const password = form.watch("password"); + const pincode = form.watch("pincode"); + const headerAuth = form.watch("headerAuth"); + + // If explicitly removed (set to `null`) it means the value has been removed + // in the other case (`undefined` or object value), check if the value has been modified + // and fallback to the policy default value + const hasPassword = + password !== null ? Boolean(password ?? policy.passwordId) : false; + + const hasPincode = + pincode !== null ? Boolean(pincode ?? policy.pincodeId) : false; + + const hasHeaderAuth = + headerAuth !== null ? Boolean(headerAuth ?? policy.headerAuth) : false; + + const [isExpanded, setIsExpanded] = useState( + hasPassword || hasPincode || hasHeaderAuth + ); + + const passwordForm = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: "" } + }); + + const pincodeForm = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: "" } + }); + + const headerAuthForm = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { user: "", password: "", extendedCompatibility: true } + }); + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + if (readonly) return; + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + + const responseArray: Array | void>> = []; + + if (typeof payload.password !== "undefined") { + responseArray.push( + api + .put>( + `/resource-policy/${policy.resourcePolicyId}/password`, + { + password: payload.password?.password ?? null + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }) + ); + } + + if (typeof payload.pincode !== "undefined") { + responseArray.push( + api + .put>( + `/resource-policy/${policy.resourcePolicyId}/pincode`, + { + pincode: payload.pincode?.pincode ?? null + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }) + ); + } + + if (typeof payload.headerAuth !== "undefined") { + responseArray.push( + api + .put>( + `/resource-policy/${policy.resourcePolicyId}/header-auth`, + { + headerAuth: payload.headerAuth + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }) + ); + } + + try { + const responseList = await Promise.all(responseArray); + + if (responseList.every((res) => res && res.status === 200)) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + if (!isExpanded) { + return ( + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + {!readonly ? ( + + ) : ( +
+

{t("resourcePolicyAuthMethodsEmpty")}

+
+ )} +
+
+ ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ +
+ + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: hasPassword + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: hasPincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {hasHeaderAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+ + + + +
+
+ + + ); +} diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx new file mode 100644 index 000000000..043ab1852 --- /dev/null +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { SettingsContainer } from "@app/components/Settings"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; + +import { orgQueries } from "@app/lib/queries"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; + +import { createApiClient } from "@app/lib/api"; +import { useRouter } from "next/navigation"; + +import { useMemo } from "react"; +import { EditPolicyAuthMethodsSectionForm } from "./EditPolicyAuthMethodsSectionForm"; +import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm"; +import { EditPolicyUsersRolesSectionForm } from "./EditPolicyUserRolesSectionForm"; +import { EditPolicyOtpEmailSectionForm } from "./EditPolicyOtpEmailSectionForm"; +import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm"; + +// ─── EditPolicyForm ───────────────────────────────────────────────────────── + +export type EditPolicyFormProps = { + hidePolicyNameForm?: boolean; + readonly?: boolean; + resourceId?: number; +}; + +export function EditPolicyForm({ + hidePolicyNameForm, + readonly, + resourceId +}: 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: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( + orgQueries.identityProviders({ + orgId: org.org.orgId, + useOrgOnlyIdp: env.app.identityProviderMode === "org" + }) + ); + + 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 (isLoadingOrgIdps) { + return <>; + } + + return ( + + {!hidePolicyNameForm && ( + + )} + + + + + + + + + + ); +} diff --git a/src/components/resource-policy/EditPolicyNameSectionForm.tsx b/src/components/resource-policy/EditPolicyNameSectionForm.tsx new file mode 100644 index 000000000..e3a2a156f --- /dev/null +++ b/src/components/resource-policy/EditPolicyNameSectionForm.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { type ResourcePolicy } from "@server/db"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; + +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 { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { useActionState } from "react"; +import { useForm } from "react-hook-form"; + +// ─── PolicyNameSection ────────────────────────────────────────────────── + +export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean }) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + + const { policy } = useResourcePolicyContext(); + + const form = useForm({ + resolver: zodResolver( + z.object({ + name: z.string() + }) + ), + defaultValues: { + name: policy.name + } + }); + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + if (readonly) return; + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}`, + { + name: payload.name + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + return ( +
+ + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + + + + + +
+ + ); +} diff --git a/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx new file mode 100644 index 000000000..16a73c672 --- /dev/null +++ b/src/components/resource-policy/EditPolicyOtpEmailSectionForm.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { createPolicySchema, type PolicyFormValues } from "."; + +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import type { AxiosResponse } from "axios"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; + +import { InfoIcon, Plus } from "lucide-react"; + +import { useActionState, useState } from "react"; +import { useForm, UseFormReturn, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; + +// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── + +type PolicyOtpEmailSectionProps = { + emailEnabled: boolean; + readonly?: boolean; +}; + +export function EditPolicyOtpEmailSectionForm({ + emailEnabled, + readonly +}: PolicyOtpEmailSectionProps) { + const t = useTranslations(); + + const { policy } = useResourcePolicyContext(); + const router = useRouter(); + + const api = createApiClient(useEnvContext()); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + emailWhitelistEnabled: true, + emails: true + }) + ), + defaultValues: { + emailWhitelistEnabled: policy.emailWhitelistEnabled, + emails: policy.emailWhiteList.map((email) => ({ + id: email.whiteListId.toString(), + text: email.email + })) + } + }); + + const whitelistEnabled = useWatch({ + control: form.control, + name: "emailWhitelistEnabled" + }); + + const [isExpanded, setIsExpanded] = useState(whitelistEnabled); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + + async function onSubmit() { + if (readonly) return; + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}/whitelist`, + { + emailWhitelistEnabled: payload.emailWhitelistEnabled, + emails: payload.emails?.map((e) => e.text) ?? [] + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + if (!isExpanded) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + {!readonly ? ( + + ) : ( +
+

{t("resourcePolicyOtpEmpty")}

+
+ )} +
+
+ ); + } + + return ( +
+ + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + form.setValue("emailWhitelistEnabled", val); + }} + disabled={readonly || !emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t( + "otpEmailEnter" + )} + tags={ + form.getValues() + .emails ?? [] + } + setTags={(newEmails) => { + if (!readonly) { + form.setValue( + "emails", + newEmails as [ + Tag, + ...Tag[] + ] + ); + } + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + + + + +
+ + ); +} diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx new file mode 100644 index 000000000..dc7bec7b3 --- /dev/null +++ b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx @@ -0,0 +1,1343 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import z from "zod"; + +import { toast } from "@app/hooks/useToast"; +import { createPolicySchema, type PolicyFormValues } from "."; + +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; + +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Check, + ChevronsUpDown, + LockIcon, + Plus +} from "lucide-react"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition +} from "react"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { resourceQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; + fromPolicy?: boolean; +}; + +type PolicyRulesSectionProps = { + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; + readonly?: boolean; + resourceId?: number; +}; + +export function EditPolicyRulesSectionForm({ + isMaxmindAvailable, + isMaxmindAsnAvailable, + readonly, + resourceId +}: PolicyRulesSectionProps) { + const t = useTranslations(); + + const { policy } = useResourcePolicyContext(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + + const isResourceOverlay = resourceId !== undefined; + + // ── Fetch resource-specific rules when in overlay mode ─────────────────── + const { data: resourceRulesData } = useQuery({ + ...resourceQueries.resourceRules({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + + const deletedResourceRuleIdsRef = useRef>(new Set()); + const [resourceRulesInitialized, setResourceRulesInitialized] = + useState(false); + + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + rules: true, + applyRules: true + }) + ), + defaultValues: { + applyRules: policy.applyRules, + rules: policy.rules + } + }); + + const rulesEnabled = useWatch({ + control: form.control, + name: "applyRules" + }); + + const [rules, setRules] = useState( + policy.rules.map((r) => ({ ...r, fromPolicy: !isResourceOverlay })) + ); + const [isExpanded, setIsExpanded] = useState( + rulesEnabled || isResourceOverlay + ); + + // Initialize resource-specific rules once fetched + useEffect(() => { + if (!isResourceOverlay || resourceRulesInitialized) return; + if (!resourceRulesData) return; + + const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId)); + const resourceSpecific: LocalRule[] = resourceRulesData + .filter((r) => !policyRuleIds.has(r.ruleId)) + .map((r) => ({ + ruleId: r.ruleId, + action: r.action as "ACCEPT" | "DROP" | "PASS", + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled, + fromPolicy: false + })); + + setRules([ + ...policy.rules.map((r) => ({ ...r, fromPolicy: true })), + ...resourceSpecific + ]); + setResourceRulesInitialized(true); + }, [ + isResourceOverlay, + resourceRulesData, + resourceRulesInitialized, + policy.rules + ]); + + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "PATH", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, syncFormRules] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const rule = rules.find((r) => r.ruleId === ruleId); + if (!rule || rule.fromPolicy) return; // cannot remove policy rules + // Track deletion for resource overlay mode (only for existing DB rules) + if (isResourceOverlay && !rule.new) { + deletedResourceRuleIdsRef.current.add(ruleId); + } + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules, isResourceOverlay] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const isLocked = row.original.fromPolicy; + if (isLocked) { + return ( + + — + + ); + } + return ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ); + } + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ {row.original.fromPolicy ? ( + + ) : ( + + )} +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule, + readonly + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + const [isPending, startTransition] = useTransition(); + + async function saveRules() { + if (readonly) return; + + if (isResourceOverlay) { + await saveResourceOverlayRules(); + return; + } + + const isValid = form.trigger(); + if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .put< + AxiosResponse<{}> + >(`/resource-policy/${policy.resourcePolicyId}/rules`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + async function saveResourceOverlayRules() { + try { + const newRules = rules.filter((r) => !r.fromPolicy && r.new); + const updatedRules = rules.filter( + (r) => !r.fromPolicy && !r.new && r.updated + ); + const deletedIds = [...deletedResourceRuleIdsRef.current]; + + await Promise.all([ + ...newRules.map((r) => + api.put(`/resource/${resourceId}/rule`, { + action: r.action, + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + ...updatedRules.map((r) => + api.post(`/resource/${resourceId}/rule/${r.ruleId}`, { + action: r.action, + match: r.match, + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + ...deletedIds.map((id) => + api.delete(`/resource/${resourceId}/rule/${id}`) + ) + ]); + + deletedResourceRuleIdsRef.current = new Set(); + + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + } + } + + if (!isExpanded) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + {!readonly ? ( + + ) : ( +
+

{t("resourcePolicyRulesEmpty")}

+
+ )} +
+
+ ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + form.setValue("applyRules", val); + }} + disabled={readonly || isResourceOverlay} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+ + + +
+ ); +} diff --git a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx new file mode 100644 index 000000000..d55a40a94 --- /dev/null +++ b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx @@ -0,0 +1,530 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; + +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { UserType } from "@server/types/UserTypes"; +import { useTranslations } from "next-intl"; + +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import type { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; +import { createPolicySchema } from "."; + +import { RolesSelector } from "@app/components/roles-selector"; +import { UsersSelector } from "@app/components/users-selector"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; + +import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; +import { resourceQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useActionState, useEffect, useMemo, useRef, useState } from "react"; +import { useForm, useWatch } from "react-hook-form"; + +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + +type PolicyUsersRolesSectionProps = { + orgId: string; + allIdps: { id: number; text: string }[]; + readonly?: boolean; + resourceId?: number; +}; + +export function EditPolicyUsersRolesSectionForm({ + orgId, + allIdps, + readonly, + resourceId +}: PolicyUsersRolesSectionProps) { + const t = useTranslations(); + + const router = useRouter(); + + const { policy } = useResourcePolicyContext(); + + const api = createApiClient(useEnvContext()); + + // ── Resource overlay: fetch resource-specific roles & users ────────────── + const isResourceOverlay = resourceId !== undefined; + + const { data: resourceRolesData } = useQuery({ + ...resourceQueries.resourceRoles({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + + const { data: resourceUsersData } = useQuery({ + ...resourceQueries.resourceUsers({ resourceId: resourceId! }), + enabled: isResourceOverlay + }); + + // IDs from the policy (locked — cannot be removed) + const policyRoleLockedIds = useMemo( + () => new Set(policy.roles.map((r) => r.roleId.toString())), + [policy.roles] + ); + const policyUserLockedIds = useMemo( + () => new Set(policy.users.map((u) => u.userId)), + [policy.users] + ); + + // Policy entries mapped to selector format + const policyRoleItems = useMemo( + () => + policy.roles.map((r) => ({ + id: r.roleId.toString(), + text: r.name + })), + [policy.roles] + ); + const policyUserItems = useMemo( + () => + policy.users.map((u) => ({ + id: u.userId, + text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` + })), + [policy.users] + ); + + // Track the initial resource-specific roles/users for diffing on save + const initialResourceRoleIdsRef = useRef>(new Set()); + const initialResourceUserIdsRef = useRef>(new Set()); + + // Combined selected roles/users (policy + resource-specific) + const [combinedRoles, setCombinedRoles] = useState(policyRoleItems); + const [combinedUsers, setCombinedUsers] = useState(policyUserItems); + const [resourceRolesInitialized, setResourceRolesInitialized] = + useState(false); + const [resourceUsersInitialized, setResourceUsersInitialized] = + useState(false); + + useEffect(() => { + if (!isResourceOverlay || resourceRolesInitialized) return; + if (!resourceRolesData) return; + + const resourceSpecific = resourceRolesData + .filter((r) => !policyRoleLockedIds.has(r.roleId.toString())) + .map((r) => ({ id: r.roleId.toString(), text: r.name })); + + initialResourceRoleIdsRef.current = new Set( + resourceSpecific.map((r) => r.id) + ); + setCombinedRoles([...policyRoleItems, ...resourceSpecific]); + setResourceRolesInitialized(true); + }, [ + isResourceOverlay, + resourceRolesData, + resourceRolesInitialized, + policyRoleItems, + policyRoleLockedIds + ]); + + useEffect(() => { + if (!isResourceOverlay || resourceUsersInitialized) return; + if (!resourceUsersData) return; + + const resourceSpecific = resourceUsersData + .filter((u) => !policyUserLockedIds.has(u.userId)) + .map((u) => ({ + id: u.userId, + text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` + })); + + initialResourceUserIdsRef.current = new Set( + resourceSpecific.map((u) => u.id) + ); + setCombinedUsers([...policyUserItems, ...resourceSpecific]); + setResourceUsersInitialized(true); + }, [ + isResourceOverlay, + resourceUsersData, + resourceUsersInitialized, + policyUserItems, + policyUserLockedIds + ]); + + // ── Standard policy form (non-overlay) ────────────────────────────────── + const form = useForm({ + resolver: zodResolver( + createPolicySchema.pick({ + sso: true, + skipToIdpId: true, + users: true, + roles: true + }) + ), + defaultValues: { + sso: policy.sso, + skipToIdpId: policy.idpId, + roles: policyRoleItems, + users: policyUserItems + } + }); + + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); + const selectedIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + + const [, formAction, isSubmitting] = useActionState(onSubmit, null); + const [isSavingOverlay, setIsSavingOverlay] = useState(false); + + async function onSubmit() { + if (readonly) return; + + if (isResourceOverlay) { + await saveResourceOverlay(); + return; + } + + const isValid = await form.trigger(); + if (!isValid) return; + + const payload = form.getValues(); + + try { + const res = await api + .put>( + `/resource-policy/${policy.resourcePolicyId}/access-control`, + { + sso: payload.sso, + userIds: payload.users.map((user) => user.id), + roleIds: payload.roles.map((role) => Number(role.id)), + skipToIdpId: payload.skipToIdpId + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: t("policyErrorUpdateMessageDescription") + }); + } + } + + async function saveResourceOverlay() { + setIsSavingOverlay(true); + try { + // Compute which roles/users are resource-specific (non-locked) + const currentResourceRoleIds = new Set( + combinedRoles + .filter((r) => !policyRoleLockedIds.has(r.id)) + .map((r) => r.id) + ); + const currentResourceUserIds = new Set( + combinedUsers + .filter((u) => !policyUserLockedIds.has(u.id)) + .map((u) => u.id) + ); + + const initialRoleIds = initialResourceRoleIdsRef.current; + const initialUserIds = initialResourceUserIdsRef.current; + + const addedRoleIds = [...currentResourceRoleIds].filter( + (id) => !initialRoleIds.has(id) + ); + const removedRoleIds = [...initialRoleIds].filter( + (id) => !currentResourceRoleIds.has(id) + ); + const addedUserIds = [...currentResourceUserIds].filter( + (id) => !initialUserIds.has(id) + ); + const removedUserIds = [...initialUserIds].filter( + (id) => !currentResourceUserIds.has(id) + ); + + await Promise.all([ + ...addedRoleIds.map((id) => + api.post(`/resource/${resourceId}/roles/add`, { + roleId: Number(id) + }) + ), + ...removedRoleIds.map((id) => + api.post(`/resource/${resourceId}/roles/remove`, { + roleId: Number(id) + }) + ), + ...addedUserIds.map((id) => + api.post(`/resource/${resourceId}/users/add`, { + userId: id + }) + ), + ...removedUserIds.map((id) => + api.post(`/resource/${resourceId}/users/remove`, { + userId: id + }) + ) + ]); + + // Update refs to reflect new state + initialResourceRoleIdsRef.current = currentResourceRoleIds; + initialResourceUserIdsRef.current = currentResourceUserIds; + + toast({ + title: t("success"), + description: t("policyUpdatedSuccess") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("policyErrorUpdate"), + description: formatAxiosError( + e, + t("policyErrorUpdateDescription") + ) + }); + } finally { + setIsSavingOverlay(false); + } + } + + const isLoading = + isResourceOverlay && + (!resourceRolesInitialized || !resourceUsersInitialized); + + return ( +
+ + + + + {t("resourceUsersRoles")} + + + {t("resourcePolicyUsersRolesDescription")} + + + + + { + form.setValue("sso", val); + }} + disabled={readonly || isResourceOverlay} + /> + + {ssoEnabled && ( + <> + + {t("roles")} + + {isResourceOverlay ? ( + + ) : ( + ( + + form.setValue( + "roles", + roles + ) + } + disabled={readonly} + restrictAdminRole + /> + )} + /> + )} + + + + {t("resourceRoleDescription")} + + + + + {t("users")} + + {isResourceOverlay ? ( + + ) : ( + ( + + form.setValue( + "users", + users + ) + } + disabled={readonly} + /> + )} + /> + )} + + + + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t( + "defaultIdentityProviderDescription" + )} +

+
+ )} +
+
+ + + + +
+
+ + ); +} diff --git a/src/components/resource-policy/ResourcePolicySubForms.tsx b/src/components/resource-policy/ResourcePolicySubForms.tsx new file mode 100644 index 000000000..1b46c79f4 --- /dev/null +++ b/src/components/resource-policy/ResourcePolicySubForms.tsx @@ -0,0 +1,1918 @@ +"use client"; + +import { + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { toast } from "@app/hooks/useToast"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { MAJOR_ASNS } from "@server/db/asns"; +import { COUNTRIES } from "@server/db/countries"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import { + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowUpDown, + Binary, + Bot, + Check, + ChevronsUpDown, + InfoIcon, + Key, + Plus +} from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { useCallback, useMemo, useState } from "react"; +import { UseFormReturn, useForm, useWatch } from "react-hook-form"; +import z from "zod"; +import type { PolicyFormValues } from "."; + +const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; + +// ─── PolicyNameSection ────────────────────────────────────────────────── +type PolicyNameSectionProps = { + form: UseFormReturn; + isEditing?: boolean; +}; + +export function PolicyNameSection({ form }: PolicyNameSectionProps) { + const t = useTranslations(); + return ( + + + + {t("resourcePolicyName")} + + + {t("resourcePolicyNameDescription")} + + + + + ( + + {t("name")} + + + + + + )} + /> + + + +
+ +
+
+ ); +} + +// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── + +type PolicyUsersRolesSectionProps = { + form: UseFormReturn; + allRoles: { id: string; text: string }[]; + allUsers: { id: string; text: string }[]; + allIdps: { id: number; text: string }[]; +}; + +export function PolicyUsersRolesSection({ + form, + allRoles, + allUsers, + allIdps +}: PolicyUsersRolesSectionProps) { + const t = useTranslations(); + const ssoEnabled = useWatch({ control: form.control, name: "sso" }); + const selectedIdpId = useWatch({ + control: form.control, + name: "skipToIdpId" + }); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + + return ( + + + + {t("resourceUsersRoles")} + + + {t("resourcePolicyUsersRolesDescription")} + + + + + { + console.log(`form.setValue("sso", ${val})`); + form.setValue("sso", val); + }} + /> + + {ssoEnabled && ( + <> + ( + + {t("roles")} + + { + form.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allRoles} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t("resourceRoleDescription")} + + + )} + /> + ( + + {t("users")} + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={true} + autocompleteOptions={allUsers} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + {ssoEnabled && allIdps.length > 0 && ( +
+ + +

+ {t("defaultIdentityProviderDescription")} +

+
+ )} +
+
+
+ ); +} + +// ─── PolicyAuthMethodsSection ───────────────────────────────────────────────── + +const setPasswordSchema = z.object({ + password: z.string().min(4).max(100) +}); + +const setPincodeSchema = z.object({ + pincode: z.string().length(6) +}); + +const setHeaderAuthSchema = z.object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() +}); + +type PolicyAuthMethodsSectionProps = { + form: UseFormReturn; +}; + +export function PolicyAuthMethodsSection({ + form +}: PolicyAuthMethodsSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); + const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); + const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); + + const password = form.watch("password"); + const pincode = form.watch("pincode"); + const headerAuth = form.watch("headerAuth"); + + const passwordForm = useForm({ + resolver: zodResolver(setPasswordSchema), + defaultValues: { password: "" } + }); + + const pincodeForm = useForm({ + resolver: zodResolver(setPincodeSchema), + defaultValues: { pincode: "" } + }); + + const headerAuthForm = useForm({ + resolver: zodResolver(setHeaderAuthSchema), + defaultValues: { user: "", password: "", extendedCompatibility: true } + }); + + if (!isOpen) { + return ( + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + + + ); + } + + return ( + <> + {/* Password Credenza */} + { + setIsSetPasswordOpen(val); + if (!val) passwordForm.reset(); + }} + > + + + + {t("resourcePasswordSetupTitle")} + + + {t("resourcePasswordSetupTitleDescription")} + + + +
+ { + form.setValue("password", data); + setIsSetPasswordOpen(false); + passwordForm.reset(); + })} + className="space-y-4" + id="set-password-form" + > + ( + + + {t("password")} + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {/* Pincode Credenza */} + { + setIsSetPincodeOpen(val); + if (!val) pincodeForm.reset(); + }} + > + + + + {t("resourcePincodeSetupTitle")} + + + {t("resourcePincodeSetupTitleDescription")} + + + +
+ { + form.setValue("pincode", data); + setIsSetPincodeOpen(false); + pincodeForm.reset(); + })} + className="space-y-4" + id="set-pincode-form" + > + ( + + + {t("resourcePincode")} + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ + + + + + +
+
+ + {/* Header Auth Credenza */} + { + setIsSetHeaderAuthOpen(val); + if (!val) headerAuthForm.reset(); + }} + > + + + + {t("resourceHeaderAuthSetupTitle")} + + + {t("resourceHeaderAuthSetupTitleDescription")} + + + +
+ { + form.setValue("headerAuth", data); + setIsSetHeaderAuthOpen(false); + headerAuthForm.reset(); + } + )} + className="space-y-4" + id="set-header-auth-form" + > + ( + + {t("user")} + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + ( + + + + + + + )} + /> + + +
+ + + + + + +
+
+ + + + + {t("resourceAuthMethods")} + + + {t("resourcePolicyAuthMethodsDescription")} + + + + + {/* Password row */} +
+
+ + + {t("resourcePasswordProtection", { + status: password + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Pincode row */} +
+
+ + + {t("resourcePincodeProtection", { + status: pincode + ? t("enabled") + : t("disabled") + })} + +
+ +
+ + {/* Header auth row */} +
+
+ + + {headerAuth + ? t( + "resourceHeaderAuthProtectionEnabled" + ) + : t( + "resourceHeaderAuthProtectionDisabled" + )} + +
+ +
+
+
+
+ + ); +} + +// ─── PolicyOtpEmailSection ──────────────────────────────────────────────────── + +type PolicyOtpEmailSectionProps = { + form: UseFormReturn; + emailEnabled: boolean; +}; + +export function PolicyOtpEmailSection({ + form, + emailEnabled +}: PolicyOtpEmailSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [whitelistEnabled, setWhitelistEnabled] = useState(false); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + if (!isOpen) { + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + + + ); + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + { + setWhitelistEnabled(val); + form.setValue("emailWhitelistEnabled", val); + }} + disabled={!emailEnabled} + /> + + {whitelistEnabled && emailEnabled && ( + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t("otpEmailEnter")} + tags={form.getValues().emails} + setTags={(newEmails) => { + form.setValue( + "emails", + newEmails as [Tag, ...Tag[]] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + )} + + + + ); +} + +// ─── PolicyRulesSection ─────────────────────────────────────────────────────── + +type PolicyRulesSectionProps = { + form: UseFormReturn; + isMaxmindAvailable: boolean; + isMaxmindAsnAvailable: boolean; +}; + +export function PolicyRulesSection({ + form, + isMaxmindAvailable, + isMaxmindAsnAvailable +}: PolicyRulesSectionProps) { + const t = useTranslations(); + const [isOpen, setIsOpen] = useState(false); + const [rules, setRules] = useState([]); + const [rulesEnabled, setRulesEnabled] = useState(false); + const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = + useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT" as const, + match: "PATH", + value: "" + } + }); + + const RuleAction = useMemo( + () => ({ + ACCEPT: t("alwaysAllow"), + DROP: t("alwaysDeny"), + PASS: t("passToAuth") + }), + [t] + ); + + const RuleMatch = useMemo( + () => ({ + PATH: t("path"), + IP: "IP", + CIDR: t("ipAddressRange"), + COUNTRY: t("country"), + ASN: "ASN" + }), + [t] + ); + + const syncFormRules = useCallback( + (updatedRules: LocalRule[]) => { + form.setValue( + "rules", + updatedRules.map( + ({ action, match, value, priority, enabled }) => ({ + action, + match, + value, + priority, + enabled + }) + ) + ); + }, + [form] + ); + + const addRule = useCallback( + function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("rulesErrorDuplicate"), + description: t("rulesErrorDuplicateDescription") + }); + return; + } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddressRange"), + description: t("rulesErrorInvalidIpAddressRangeDescription") + }); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidUrl"), + description: t("rulesErrorInvalidUrlDescription") + }); + return; + } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidIpAddress"), + description: t("rulesErrorInvalidIpAddressDescription") + }); + return; + } + if ( + data.match === "COUNTRY" && + !COUNTRIES.some((c) => c.code === data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidCountry"), + description: t("rulesErrorInvalidCountryDescription") || "" + }); + return; + } + + let priority = data.priority; + if (priority === undefined) { + priority = + rules.reduce( + (acc, rule) => + rule.priority > acc ? rule.priority : acc, + 0 + ) + 1; + } + + const updatedRules = [ + ...rules, + { + ...data, + ruleId: new Date().getTime(), + new: true, + priority, + enabled: true + } + ]; + setRules(updatedRules); + syncFormRules(updatedRules); + addRuleForm.reset(); + }, + [rules, t, addRuleForm, syncFormRules] + ); + + const removeRule = useCallback( + function removeRule(ruleId: number) { + const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const updateRule = useCallback( + function updateRule(ruleId: number, data: Partial) { + const updatedRules = rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ); + setRules(updatedRules); + syncFormRules(updatedRules); + }, + [rules, syncFormRules] + ); + + const getValueHelpText = useCallback( + function getValueHelpText(type: string) { + switch (type) { + case "CIDR": + return t("rulesMatchIpAddressRangeDescription"); + case "IP": + return t("rulesMatchIpAddress"); + case "PATH": + return t("rulesMatchUrl"); + case "COUNTRY": + return t("rulesMatchCountry"); + case "ASN": + return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + } + }, + [t] + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "priority", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + e.currentTarget.focus()} + onBlur={(e) => { + const parsed = z.coerce + .number() + .int() + .optional() + .safeParse(e.target.value); + if (!parsed.success) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidPriority"), + description: t( + "rulesErrorInvalidPriorityDescription" + ) + }); + return; + } + updateRule(row.original.ruleId, { + priority: parsed.data + }); + }} + /> + ) + }, + { + accessorKey: "action", + header: () => {t("rulesAction")}, + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: () => ( + {t("rulesMatchType")} + ), + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: () => {t("value")}, + cell: ({ row }) => + row.original.match === "COUNTRY" ? ( + + + + + + + + + + {t("noCountryFound")} + + + {COUNTRIES.map((country) => ( + + updateRule( + row.original.ruleId, + { + value: country.code + } + ) + } + > + + {country.name} ( + {country.code}) + + ))} + + + + + + ) : row.original.match === "ASN" ? ( + + + + + + + + + + No ASN found. Enter a custom ASN + below. + + + {MAJOR_ASNS.map((asn) => ( + + updateRule( + row.original.ruleId, + { value: asn.code } + ) + } + > + + {asn.name} ({asn.code}) + + ))} + + + +
+ + asn.code === + row.original.value + ) + ? row.original.value + : "" + } + onKeyDown={(e) => { + if (e.key === "Enter") { + const value = + e.currentTarget.value + .toUpperCase() + .replace(/^AS/, ""); + if (/^\d+$/.test(value)) { + updateRule( + row.original.ruleId, + { value: "AS" + value } + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + accessorKey: "enabled", + header: () => {t("enabled")}, + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { enabled: val }) + } + /> + ) + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => ( +
+ +
+ ) + } + ], + [ + t, + RuleAction, + RuleMatch, + isMaxmindAvailable, + isMaxmindAsnAvailable, + updateRule, + removeRule + ] + ); + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { pagination: { pageIndex: 0, pageSize: 1000 } } + }); + + if (!isOpen) { + return ( + + + + {t("rulesResource")} + + + {t("rulesResourcePolicyDescription")} + + + + + + + ); + } + + return ( + + + + {t("rulesResource")} + + + {t("rulesResourceDescription")} + + + +
+
+ { + setRulesEnabled(val); + form.setValue("applyRules", val); + }} + /> +
+ +
+ +
+ ( + + + {t("rulesAction")} + + + + + + + )} + /> + ( + + + {t("rulesMatchType")} + + + + + + + )} + /> + ( + + + + {addRuleForm.watch("match") === + "COUNTRY" ? ( + + + + + + + + + + {t( + "noCountryFound" + )} + + + {COUNTRIES.map( + ( + country + ) => ( + { + field.onChange( + country.code + ); + setOpenAddRuleCountrySelect( + false + ); + }} + > + + { + country.name + }{" "} + ( + { + country.code + } + + ) + + ) + )} + + + + + + ) : addRuleForm.watch( + "match" + ) === "ASN" ? ( + + + + + + + + + + No ASN + found. + Use the + custom + input + below. + + + {MAJOR_ASNS.map( + ( + asn + ) => ( + { + field.onChange( + asn.code + ); + setOpenAddRuleAsnSelect( + false + ); + }} + > + + { + asn.name + }{" "} + ( + { + asn.code + } + + ) + + ) + )} + + + +
+ { + if ( + e.key === + "Enter" + ) { + const value = + e.currentTarget.value + .toUpperCase() + .replace( + /^AS/, + "" + ); + if ( + /^\d+$/.test( + value + ) + ) { + field.onChange( + "AS" + + value + ); + setOpenAddRuleAsnSelect( + false + ); + } + } + }} + className="text-sm" + /> +
+
+
+ ) : ( + + )} +
+ +
+ )} + /> + +
+
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column + .columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const isActionsColumn = + cell.column.id === "actions"; + return ( + + {flexRender( + cell.column.columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + {t("rulesNoOne")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/resource-policy/index.ts b/src/components/resource-policy/index.ts new file mode 100644 index 000000000..8579a6de5 --- /dev/null +++ b/src/components/resource-policy/index.ts @@ -0,0 +1,65 @@ +// ─── Schemas & types ────────────────────────────────────────────────────────── + +import z from "zod"; + +export const createPolicySchema = z.object({ + name: z.string().min(1).max(255), + sso: z.boolean().default(true), + skipToIdpId: z.number().nullable().optional(), + emailWhitelistEnabled: z.boolean().default(false), + roles: z.array(z.object({ id: z.string(), text: z.string() })), + users: z.array(z.object({ id: z.string(), text: z.string() })), + emails: z.array(z.object({ id: z.string(), text: z.string() })), + password: z + .object({ + password: z.string().min(4).max(100) + }) + .nullable() + .default(null), + pincode: z + .object({ + pincode: z.string().regex(/^\d{6}$/) + }) + .nullable() + .default(null), + headerAuth: z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean().default(true) + }) + .nullable() + .default(null), + applyRules: z.boolean().default(false), + rules: z + .array( + z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.number().int(), + enabled: z.boolean() + }) + ) + .default([]) +}); + +export type PolicyFormValues = z.infer; + +export const addRuleSchema = z.object({ + action: z.enum(["ACCEPT", "DROP", "PASS"]), + match: z.string(), + value: z.string(), + priority: z.coerce.number().int().optional() +}); + +export type LocalRule = { + ruleId: number; + action: "ACCEPT" | "DROP" | "PASS"; + match: string; + value: string; + priority: number; + enabled: boolean; + new?: boolean; + updated?: boolean; +}; diff --git a/src/components/roles-selector.tsx b/src/components/roles-selector.tsx index 811971f49..e18d13383 100644 --- a/src/components/roles-selector.tsx +++ b/src/components/roles-selector.tsx @@ -16,6 +16,7 @@ export type RolesSelectorProps = { restrictAdminRole?: boolean; mapRolesByName?: boolean; buttonText?: string; + lockedIds?: Set; }; export function RolesSelector({ @@ -25,7 +26,8 @@ export function RolesSelector({ disabled, restrictAdminRole, mapRolesByName, - buttonText + buttonText, + lockedIds }: RolesSelectorProps) { const t = useTranslations(); const [roleSearchQuery, setRoleSearchQuery] = useState(""); @@ -76,6 +78,7 @@ export function RolesSelector({ value={selectedRoles} onChange={onSelectRoles} disabled={disabled} + lockedIds={lockedIds} /> ); } diff --git a/src/components/tags/tag-popover.tsx b/src/components/tags/tag-popover.tsx index 93f5e2c04..65f14cac4 100644 --- a/src/components/tags/tag-popover.tsx +++ b/src/components/tags/tag-popover.tsx @@ -1,10 +1,16 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "../ui/popover"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState +} from "react"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; import { TagList, TagListProps } from "./tag-list"; import { Button } from "../ui/button"; @@ -47,7 +53,7 @@ export const TagPopover: React.FC = ({ const t = useTranslations(); - useEffect(() => { + useLayoutEffect(() => { const handleResize = () => { if (triggerContainerRef.current) { setPopoverWidth(triggerContainerRef.current.offsetWidth); diff --git a/src/components/users-selector.tsx b/src/components/users-selector.tsx index 5cbe90e89..ce96a3855 100644 --- a/src/components/users-selector.tsx +++ b/src/components/users-selector.tsx @@ -18,12 +18,16 @@ export type UsersSelectorProps = { orgId: string; selectedUsers?: SelectedUser[]; onSelectUsers: (users: SelectedUser[]) => void; + disabled?: boolean; + lockedIds?: Set; }; export function UsersSelector({ orgId, selectedUsers = [], - onSelectUsers + onSelectUsers, + disabled, + lockedIds }: UsersSelectorProps) { const t = useTranslations(); const [userSearchQuery, setUserSearchQuery] = useState(""); @@ -58,6 +62,8 @@ export function UsersSelector({ options={usersShown} value={selectedUsers} onChange={onSelectUsers} + disabled={disabled} + lockedIds={lockedIds} /> ); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 1b80619c2..17377af8f 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,5 +1,4 @@ import { build } from "@server/build"; -import { StatusHistoryResponse } from "@server/lib/statusHistory"; import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { @@ -14,11 +13,14 @@ import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetDomainResponse } from "@server/routers/domain/getDomain"; -import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import type { GetResourceWhitelistResponse, + GetResourcePoliciesResponse, ListResourceNamesResponse, - ListResourcesResponse + ListResourcesResponse, + ListResourceRolesResponse, + ListResourceRulesResponse, + ListResourceUsersResponse } from "@server/routers/resource"; import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; @@ -40,6 +42,9 @@ import z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; +import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; +import { StatusHistoryResponse } from "@server/lib/statusHistory"; +import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; export type ProductUpdate = { link: string | null; @@ -574,6 +579,28 @@ export const orgQueries = { ); return res.data.data; } + }), + + policies: ({ orgId, name }: { orgId: string; name?: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "RESOURCES_POLICIES", name] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: "10" + }); + + if (name) { + sp.set("query", name); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/resource-policies?${sp.toString()}`, { + signal + }); + + return res.data.data.policies; + } }) }; @@ -859,7 +886,7 @@ export const resourceQueries = { queryKey: ["RESOURCES", resourceId, "USERS"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse + AxiosResponse >(`/resource/${resourceId}/users`, { signal }); return res.data.data.users; } @@ -869,12 +896,23 @@ export const resourceQueries = { queryKey: ["RESOURCES", resourceId, "ROLES"] as const, queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< - AxiosResponse + AxiosResponse >(`/resource/${resourceId}/roles`, { signal }); return res.data.data.roles; } }), + resourceRules: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "RULES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource/${resourceId}/rules`, { signal }); + + return res.data.data.rules; + } + }), siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) => queryOptions({ queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const, @@ -929,6 +967,17 @@ export const resourceQueries = { return res.data.data.whitelist; } }), + policies: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "POLICIES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/resource/${resourceId}/policies`, { signal }); + + return res.data.data; + } + }), listNamesPerOrg: (orgId: string) => queryOptions({ queryKey: ["RESOURCES_NAMES", orgId] as const, diff --git a/src/providers/ResourcePolicyProvider.tsx b/src/providers/ResourcePolicyProvider.tsx new file mode 100644 index 000000000..e80704dc4 --- /dev/null +++ b/src/providers/ResourcePolicyProvider.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; +import { useTranslations } from "next-intl"; +import type { GetResourcePolicyResponse } from "@server/routers/policy"; + +interface ResourcePolicyProviderProps { + children: React.ReactNode; + policy: GetResourcePolicyResponse; +} + +export function ResourcePolicyProvider({ + children, + policy: serverPolicy +}: ResourcePolicyProviderProps) { + const [policy, setPolicy] = + useState(serverPolicy); + + const t = useTranslations(); + + const updatePolicy = ( + updatedPolicy: Partial + ) => { + if (!policy) { + throw new Error(t("resourceErrorNoUpdate")); + } + + setPolicy((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + ...updatedPolicy + }; + }); + }; + + return ( + + {children} + + ); +} + +export type ResourcePolicyContextType = { + policy: GetResourcePolicyResponse; + updatePolicy: (updatedPolicy: Partial) => void; +}; + +export const ResourcePolicyContext = createContext< + ResourcePolicyContextType | undefined +>(undefined); + +export function useResourcePolicyContext() { + const context = useContext(ResourcePolicyContext); + if (context === undefined) { + throw new Error( + "useResourcePolicyContext must be used within a ResourcePolicyProvider" + ); + } + return context; +}