diff --git a/.gitignore b/.gitignore index 004f95c18..3e03b1112 100644 --- a/.gitignore +++ b/.gitignore @@ -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 ee4ef143d..4bf8e8806 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -204,11 +204,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", @@ -249,6 +271,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", "visibility": "Visibility", @@ -261,6 +285,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", @@ -731,6 +757,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:", @@ -794,6 +830,15 @@ "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 in this page.", + "resourcePolicyTypeSave": "Save Resource type", + "resourcePolicySelect": "Select resource policy", + "resourcePolicySelectError": "Select a resource policy", + "resourcePolicyNotFound": "Policy not found", + "resourcePolicyRulesEmpty": "No authentication rules", "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", "resourceAuthSettingsSave": "Saved successfully", "resourceAuthSettingsSaveDescription": "Authentication settings have been saved", @@ -829,6 +874,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", @@ -1358,6 +1409,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 9ba1b5bce..23a5d73e1 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -152,7 +152,21 @@ export enum ActionsEnum { createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", deleteHealthCheck = "deleteHealthCheck", - listHealthChecks = "listHealthChecks" + listHealthChecks = "listHealthChecks", + 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( 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 7fbcef621..b2d125927 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -110,6 +110,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() @@ -196,9 +206,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), - siteId: integer("siteId").references(() => sites.siteId, { - onDelete: "cascade" - }).notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), name: varchar("name"), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), @@ -521,6 +533,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") @@ -586,6 +630,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") @@ -679,6 +757,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(), @@ -1097,19 +1212,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", { complete: boolean("complete").notNull().default(false) }); -export const statusHistory = pgTable("statusHistory", { - id: serial("id").primaryKey(), - entityType: varchar("entityType").notNull(), - entityId: integer("entityId").notNull(), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - status: varchar("status").notNull(), - timestamp: integer("timestamp").notNull(), -}, (table) => [ - index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), - index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), -]); +export const statusHistory = pgTable( + "statusHistory", + { + id: serial("id").primaryKey(), + entityType: varchar("entityType").notNull(), + entityId: integer("entityId").notNull(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: varchar("status").notNull(), + timestamp: integer("timestamp").notNull() + }, + (table) => [ + index("idx_statusHistory_entity").on( + table.entityType, + table.entityId, + table.timestamp + ), + index("idx_statusHistory_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); export type Org = InferSelectModel; export type User = InferSelectModel; @@ -1179,3 +1305,6 @@ export type RoundTripMessageTracker = InferSelectModel< >; export type Network = InferSelectModel; export type StatusHistory = InferSelectModel; +export type ResourcePolicy = InferSelectModel; +export type RolePolicy = InferSelectModel; +export type UserPolicy = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 423190420..4a8c38318 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -121,6 +121,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() @@ -219,9 +229,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { onDelete: "cascade" }) .notNull(), - siteId: integer("siteId").references(() => sites.siteId, { - onDelete: "cascade" - }).notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), name: text("name"), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() @@ -909,6 +921,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", { @@ -1023,6 +1076,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(), @@ -1196,19 +1320,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", { complete: integer("complete", { mode: "boolean" }).notNull().default(false) }); -export const statusHistory = sqliteTable("statusHistory", { - id: integer("id").primaryKey({ autoIncrement: true }), - entityType: text("entityType").notNull(), // "site" | "healthCheck" - entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId - orgId: text("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks - timestamp: integer("timestamp").notNull(), // unix epoch seconds -}, (table) => [ - index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), - index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), -]); +export const statusHistory = sqliteTable( + "statusHistory", + { + id: integer("id").primaryKey({ autoIncrement: true }), + entityType: text("entityType").notNull(), // "site" | "healthCheck" + entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks + timestamp: integer("timestamp").notNull() // unix epoch seconds + }, + (table) => [ + index("idx_statusHistory_entity").on( + table.entityType, + table.entityId, + table.timestamp + ), + index("idx_statusHistory_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); export type Org = InferSelectModel; export type User = InferSelectModel; @@ -1278,3 +1413,6 @@ export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; export type StatusHistory = InferSelectModel; +export type ResourcePolicy = InferSelectModel; +export type RolePolicy = InferSelectModel; +export type UserPolicy = InferSelectModel; 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..83eb69d7f --- /dev/null +++ b/server/middlewares/verifyResourcePolicyAccess.ts @@ -0,0 +1,125 @@ +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"; + +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.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = policy.orgId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource policy access" + ) + ); + } +} 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 a2667daa1..7e5fc8f67 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,6 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; +import * as resource from "#private/routers/resource"; +import * as policy from "#private/routers/policy"; import { verifyOrgAccess, @@ -44,7 +46,8 @@ import { verifyUserCanSetUserOrgRoles, verifySiteProvisioningKeyAccess, verifyIsLoggedInUser, - verifyAdmin + verifyAdmin, + verifyResourcePolicyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -382,6 +385,39 @@ authenticated.get( approval.countApprovals ); +authenticated.delete( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyValidLicense, + // verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? + verifyLimits, + verifyUserHasAction(ActionsEnum.deleteResourcePolicy), + logActionAudit(ActionsEnum.deleteResourcePolicy), + policy.deleteResourcePolicy +); + +authenticated.get( + "/org/:orgId/resource-policies", + verifyValidLicense, + // verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.listResourcePolicies), + logActionAudit(ActionsEnum.listResourcePolicies), + policy.listResourcePolicies +); + +authenticated.post( + "/org/:orgId/resource-policy", + verifyValidLicense, + // verifyValidSubscription(tierMatrix.loginPageDomain), // todo: use the correct subscription ? + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createResourcePolicy), + logActionAudit(ActionsEnum.createResourcePolicy), + policy.createResourcePolicy +); + authenticated.put( "/org/:orgId/approvals/:approvalId", verifyValidLicense, diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts new file mode 100644 index 000000000..1bbdfe153 --- /dev/null +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -0,0 +1,401 @@ +import { hashPassword } from "@server/auth/password"; +import { + db, + idp, + idpOrg, + orgs, + resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, + resourcePolicyRules, + resourcePolicyWhiteList, + rolePolicies, + roles, + userOrgs, + userPolicies, + users, + type ResourcePolicy +} from "@server/db"; +import { getUniqueResourcePolicyName } from "@server/db/names"; +import response from "@server/lib/response"; +import { + isValidCIDR, + isValidIP, + isValidUrlGlobPattern +} from "@server/lib/validators"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, inArray, type InferInsertModel } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +const createResourcePolicyParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const ruleSchema = z.strictObject({ + action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({ + type: "string", + enum: ["ACCEPT", "DROP", "PASS"], + description: "rule action" + }), + match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + type: "string", + enum: ["CIDR", "IP", "PATH"], + description: "rule match" + }), + value: z.string().min(1), + priority: z.int().openapi({ + type: "integer", + description: "Rule priority" + }), + enabled: z.boolean().optional() +}); + +const createResourcePolicyBodySchema = z.strictObject({ + name: z.string().min(1).max(255), + // Access control + sso: z.boolean().default(true), + skipToIdpId: z + .int() + .positive() + .optional() + .nullable() + .openapi({ type: "integer" }), + roleIds: z + .array(z.string().transform(Number).pipe(z.int().positive())) + .optional() + .default([]), + userIds: z.array(z.string()).optional().default([]), + // auth methods + password: z.string().min(4).max(100).nullable().optional(), + pincode: z + .string() + .regex(/^\d{6}$/) + .or(z.null()) + .optional(), + headerAuth: z + .object({ + user: z.string().min(4).max(100), + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() + }) + .nullable() + .optional(), + // email OTP + emailWhitelistEnabled: z.boolean().optional().default(false), + emails: z + .array( + z.email().or( + z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { + error: "Invalid email address. Wildcard (*) must be the entire local part." + }) + ) + ) + .max(50) + .transform((v) => v.map((e) => e.toLowerCase())) + .optional() + .default([]), + // rules + applyRules: z.boolean().default(false), + rules: z.array(ruleSchema).optional().default([]) +}); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/resource-policy", + description: "Create a resource policy.", + tags: [OpenAPITags.Org, OpenAPITags.Policy], + request: { + params: createResourcePolicyParamsSchema, + body: { + content: { + "application/json": { + schema: createResourcePolicyBodySchema + } + } + } + }, + responses: {} +}); + +export async function createResourcePolicy( + req: Request, + res: Response, + next: NextFunction +) { + try { + // Validate request params + const parsedParams = createResourcePolicyParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId } = parsedParams.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + // get the org + const org = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (org.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ) + ); + } + + const parsedBody = createResourcePolicyBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { + name, + sso, + userIds, + roleIds, + skipToIdpId, + applyRules, + emailWhitelistEnabled, + password, + pincode, + headerAuth, + emails, + rules + } = parsedBody.data; + + // Check if Identity provider in `skipToIdpId` exists + if (skipToIdpId) { + const [provider] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId)) + .where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + if (!provider) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Identity provider not found in this organization" + ) + ); + } + } + + const adminRole = await db + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + const existingRoles = await db + .select() + .from(roles) + .where(and(inArray(roles.roleId, roleIds))); + + const hasAdminRole = existingRoles.some((role) => role.isAdmin); + + if (hasAdminRole) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to resource policy" + ) + ); + } + + const existingUsers = await db + .select() + .from(users) + .innerJoin(userOrgs, eq(userOrgs.userId, users.userId)) + .where( + and(eq(userOrgs.orgId, orgId), inArray(users.userId, userIds)) + ); + + const niceId = await getUniqueResourcePolicyName(orgId); + + for (const rule of rules) { + if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid CIDR provided" + ) + ); + } else if (rule.match === "IP" && !isValidIP(rule.value)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") + ); + } else if ( + rule.match === "PATH" && + !isValidUrlGlobPattern(rule.value) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid URL glob pattern provided" + ) + ); + } + } + + const policy = await db.transaction(async (trx) => { + const [newPolicy] = await trx + .insert(resourcePolicies) + .values({ + niceId, + orgId, + name, + sso, + idpId: skipToIdpId, + applyRules, + emailWhitelistEnabled + }) + .returning(); + + const rolesToAdd = [ + { + roleId: adminRole[0].roleId, + resourcePolicyId: newPolicy.resourcePolicyId + } + ] satisfies InferInsertModel[]; + + rolesToAdd.push( + ...existingRoles.map((role) => ({ + roleId: role.roleId, + resourcePolicyId: newPolicy.resourcePolicyId + })) + ); + + await trx.insert(rolePolicies).values(rolesToAdd); + + const usersToAdd: InferInsertModel[] = []; + + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + // make sure the user can access the policy + 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..17a9a68f9 --- /dev/null +++ b/server/private/routers/policy/deleteResourcePolicy.ts @@ -0,0 +1,107 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { db, resourcePolicies, resources } from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import z from "zod"; +import { fromError } from "zod-validation-error"; + +// Define Zod schema for request parameters validation +const deleteResourcePolicySchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "delete", + path: "/resource-policy/{resourcePolicyId}", + description: "Delete a resource policy.", + tags: [OpenAPITags.Policy], + request: { + params: deleteResourcePolicySchema + }, + responses: {} +}); + +export async function deleteResourcePolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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..1fb73a58c --- /dev/null +++ b/server/private/routers/policy/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createResourcePolicy"; +export * from "./listResourcePolicies"; +export * from "./deleteResourcePolicy"; diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts new file mode 100644 index 000000000..58a83df04 --- /dev/null +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -0,0 +1,261 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + db, + resourcePolicies, + resources, + rolePolicies, + userPolicies +} from "@server/db"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import type { + ListResourcePoliciesResponse, + ResourcePolicyWithResources +} from "@server/routers/resource/types"; +import HttpCode from "@server/types/HttpCode"; +import { and, asc, eq, inArray, like, or, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; + +const listResourcePoliciesParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const listResourcePoliciesSchema = z.object({ + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) + .optional() + .catch(1) + .default(1), + query: z.string().optional() +}); + +function queryResourcePoliciesBase() { + return db + .select({ + resourcePolicyId: resourcePolicies.resourcePolicyId, + name: resourcePolicies.name, + niceId: resourcePolicies.niceId, + orgId: resourcePolicies.orgId + }) + .from(resourcePolicies); +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resource-policies", + description: "List resource policies for an organization.", + tags: [OpenAPITags.Org, OpenAPITags.Policy], + request: { + params: z.object({ + orgId: z.string() + }), + query: listResourcePoliciesSchema + }, + responses: {} +}); + +export async function listResourcePolicies( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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), + eq(rolePolicies.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleResourcePolicies = await db + .select({ + resourcePolicyId: resourcePolicies.resourcePolicyId + }) + .from(resourcePolicies) + .where(eq(resourcePolicies.orgId, orgId)); + } + + const accessibleResourceIds = accessibleResourcePolicies.map( + (resource) => resource.resourcePolicyId + ); + + const conditions = [ + and( + inArray( + resourcePolicies.resourcePolicyId, + accessibleResourceIds + ), + eq(resourcePolicies.orgId, orgId), + eq(resourcePolicies.scope, "global") + ) + ]; + + if (query) { + conditions.push( + or( + like( + sql`LOWER(${resourcePolicies.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resourcePolicies.niceId})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } + + const baseQuery = queryResourcePoliciesBase().where(and(...conditions)); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count(baseQuery.as("filtered_policies")); + + const [rows, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(resourcePolicies.resourcePolicyId)), + countQuery + ]); + + const attachedResources = + rows.length === 0 + ? [] + : await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + resourcePolicyId: resources.resourcePolicyId + }) + .from(resources) + .where( + inArray( + resources.resourcePolicyId, + rows.map((row) => row.resourcePolicyId) + ) + ); + + // avoids TS issues with reduce/never[] + const map = new Map(); + + 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/external.ts b/server/routers/external.ts index a17c88fb1..31c8fccfb 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"; @@ -540,6 +542,7 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); + authenticated.post( "/resource/:resourceId", verifyResourceAccess, @@ -646,6 +649,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 +723,59 @@ authenticated.post( resource.setResourceUsers ); +authenticated.put( + "/resource-policy/:resourcePolicyId/access-control", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + 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..77443e1a2 --- /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.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + const { resourcePolicyId } = parsedParams.data; + const [result] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .leftJoin(orgs, eq(resourcePolicies.orgId, orgs.orgId)); + + const policy = result?.resourcePolicies; + const org = result?.orgs; + + if (!policy || !org) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource Policy with ID ${resourcePolicyId} not found` + ) + ); + } + + const parsedBody = updateResourcePolicyBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const updateData = parsedBody.data; + + if (updateData.niceId) { + const [existingPolicy] = await db + .select() + .from(resourcePolicies) + .where( + and( + eq(resourcePolicies.niceId, updateData.niceId), + eq(resourcePolicies.orgId, policy.orgId) + ) + ); + + if ( + existingPolicy && + existingPolicy.resourcePolicyId !== policy.resourcePolicyId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A resource policy with niceId "${updateData.niceId}" already exists` + ) + ); + } + } + + const updatedPolicy = await db.transaction(async (trx) => { + const [updated] = await trx + .update(resourcePolicies) + .set({ + ...updateData + }) + .where( + eq( + resourcePolicies.resourcePolicyId, + policy.resourcePolicyId + ) + ) + .returning(); + + return updated; + }); + + if (!updatedPolicy) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update policy" + ) + ); + } + + return response(res, { + data: updatedPolicy, + success: true, + error: false, + message: "Resource policy updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index f8b7551e9..b14da3743 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,13 +24,18 @@ 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 } from "@server/lib/domainUtils"; +import { + validateAndConstructDomain, + checkWildcardDomainConflict +} from "@server/lib/domainUtils"; 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() @@ -311,8 +320,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.userOrgRoleId !== adminRole[0].roleId) { + await trx.insert(userPolicies).values({ + userId: req.user?.userId!, + resourcePolicyId: defaultPolicy.resourcePolicyId + }); + } + const newResource = await trx .insert(resources) .values({ @@ -328,22 +375,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 @@ -369,7 +405,7 @@ async function createHttpResource( ); } - if (build != "oss") { + if (build !== "oss") { await createCertificate(domainId, fullDomain, db); } @@ -410,22 +446,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, - http, - protocol, - proxyPort - // enableProxy - }) - .returning(); - - const adminRole = await db + const adminRole = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) @@ -437,6 +461,44 @@ 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.userOrgRoleId != 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, + http, + protocol, + 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 682fd6aa9..0a683e1f0 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 6a259d7fe..83dbdea2a 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -33,3 +33,4 @@ export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; export * from "./getStatusHistory"; +export * from "./getResourcePolicies"; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 16a82e400..5b679be5a 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,9 +1,9 @@ import { db, - resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility, - resourcePassword, - resourcePincode, + resourcePolicies, + resourcePolicyHeaderAuth, + resourcePolicyPassword, + resourcePolicyPincode, resources, roleResources, sites, @@ -163,10 +163,10 @@ 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, http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, @@ -174,29 +174,45 @@ function queryResourcesBase() { domainId: resources.domainId, niceId: resources.niceId, wildcard: resources.wildcard, - headerAuthId: resourceHeaderAuth.headerAuthId, - headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, - health: resources.health + health: resources.health, + 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)) @@ -206,10 +222,10 @@ function queryResourcesBase() { ) .groupBy( resources.resourceId, - resourcePassword.passwordId, - resourcePincode.pincodeId, - resourceHeaderAuth.headerAuthId, - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + resourcePolicies.resourcePolicyId, + resourcePolicyPassword.passwordId, + resourcePolicyPincode.pincodeId, + resourcePolicyHeaderAuth.headerAuthId ); } @@ -355,21 +371,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; } @@ -446,9 +462,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, http: row.http, protocol: row.protocol, proxyPort: row.proxyPort, 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 0a7052dce..d36e49e87 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -7,6 +7,7 @@ import { orgDomains, orgs, Resource, + resourcePolicies, resources } from "@server/db"; import { eq, and, ne } from "drizzle-orm"; @@ -68,7 +69,8 @@ const updateHttpResourceBodySchema = z maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceEstimatedTime: z.string().max(100).nullable().optional(), - postAuthPath: z.string().nullable().optional() + postAuthPath: z.string().nullable().optional(), + resourcePolicyId: z.number().nullable().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -165,7 +167,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" @@ -301,6 +304,23 @@ async function updateHttpResource( const updateData = parsedBody.data; + if (updateData.resourcePolicyId != null) { + 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() @@ -536,6 +556,23 @@ async function updateRawResource( const updateData = parsedBody.data; + if (updateData.resourcePolicyId != null) { + 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() diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index fc4ea5be1..c00699523 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -135,7 +135,7 @@ const listSitesSchema = z.object({ page: z.coerce .number() // for prettier formatting .int() - .min(0) + .positive() .optional() .catch(1) .default(1) 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/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 12f511078..4b1e9f516 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -1,95 +1,73 @@ "use client"; -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 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 { useQuery, useQueryClient } from "@tanstack/react-query"; -import SetResourcePasswordForm from "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(); @@ -97,906 +75,264 @@ export default function ResourceAuthenticationPage() { 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 [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - - 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)} + {build !== "oss" && ( + + + + {t("resourcePolicySelectTitle")} + + + {t("resourcePolicySelectDescription")} + + + + { + form.setValue("type", value); + }} + cols={2} /> - -
- - {ssoEnabled && ( - <> - ( - - - {t("roles")} - - - { - usersRolesForm.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t( - "resourceRoleDescription" - )} - - - )} - /> - ( - - - {t("users")} - - - { - usersRolesForm.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - {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 2f6cd1492..8adea5631 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 65d671681..a1d62585f 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -92,7 +92,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"; @@ -218,7 +224,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(""); @@ -328,7 +334,7 @@ export default function Page() { id: "raw" as ResourceType, title: t("resourceRaw"), description: - build == "saas" + build === "saas" ? t("resourceRawDescriptionCloud") : t("resourceRawDescription") } @@ -473,8 +479,6 @@ export default function Page() { ); async function onSubmit() { - setCreateLoading(true); - const baseData = baseForm.getValues(); const isHttp = baseData.http; @@ -610,8 +614,6 @@ export default function Page() { ) }); } - - setCreateLoading(false); } useEffect(() => { @@ -1465,7 +1467,7 @@ export default function Page() { console.log(httpForm.getValues()); if (baseValid && settingsValid) { - onSubmit(); + startTransition(onSubmit); } }} loading={createLoading} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 24dc02a19..64528a6bf 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, TicketCheck, Unplug, @@ -99,7 +101,7 @@ export const orgNavSections = ( href: "/{orgId}/settings/domains", icon: }, - ...(build == "saas" + ...(build === "saas" ? [ { title: "sidebarRemoteExitNodes", @@ -134,6 +136,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 ca49a50ae..bae2b04d3 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 813d232cc..fba6b0832 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -193,22 +193,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) { @@ -770,7 +765,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..bfdc49724 --- /dev/null +++ b/src/components/ResourcePoliciesTable.tsx @@ -0,0 +1,306 @@ +"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"; + +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 e3138550b..87dacbd4e 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -61,12 +61,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) => (