diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 21d378802..114a6b217 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -21,7 +21,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretVersioning: true, pitRecovery: false, ipAllowlisting: false, - rbac: false, + rbac: true, customRateLimits: false, customAlerts: false, secretAccessInsights: false, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 72d36c26b..49ee44ab2 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -57,6 +57,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; +import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; @@ -77,7 +78,6 @@ import { TSecretApprovalDetailsDTO, TStatusChangeDTO } from "./secret-approval-request-types"; -import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; type TSecretApprovalRequestServiceFactoryDep = { permissionService: Pick; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index dcedf5fa5..97317c62e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { IntegrationsSchema, + ProjectEnvironmentsSchema, ProjectMembershipsSchema, ProjectRolesSchema, ProjectSlackConfigsSchema, ProjectType, + SecretFoldersSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; @@ -675,4 +677,31 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return slackConfig; } }); + + server.route({ + method: "GET", + url: "/:workspaceId/folders/project-environments", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.record( + ProjectEnvironmentsSchema.extend({ folders: SecretFoldersSchema.extend({ path: z.string() }).array() }) + ) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const environmentsFolders = await server.services.folder.getProjectEnvironmentsFolders( + req.params.workspaceId, + req.permission + ); + + return environmentsFolders; + } + }); }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index aabc35683..1cc275cad 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; -import { ActionProjectType, TSecretFoldersInsert } from "@app/db/schemas"; +import { ActionProjectType, TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; @@ -27,7 +27,7 @@ type TSecretFolderServiceFactoryDep = { permissionService: Pick; snapshotService: Pick; folderDAL: TSecretFolderDALFactory; - projectEnvDAL: Pick; + projectEnvDAL: Pick; folderVersionDAL: TSecretFolderVersionDALFactory; projectDAL: Pick; }; @@ -580,6 +580,51 @@ export const secretFolderServiceFactory = ({ return folders; }; + const getProjectEnvironmentsFolders = async (projectId: string, actor: OrgServiceActor) => { + // folder list is allowed to be read by anyone + // permission is to check if user has access + await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const environments = await projectEnvDAL.find({ projectId }); + + const folders = await folderDAL.find({ + $in: { + envId: environments.map((env) => env.id) + }, + isReserved: false + }); + + const environmentFolders = Object.fromEntries( + environments.map((env) => { + const relevantFolders = folders.filter((folder) => folder.envId === env.id); + const foldersMap = Object.fromEntries(relevantFolders.map((folder) => [folder.id, folder])); + + const foldersWithPath = relevantFolders.map((folder) => ({ + ...folder, + path: folder.parentId + ? (function buildPath(f: TSecretFolders): string { + if (!f.parentId) { + return ""; + } + return `${buildPath(foldersMap[f.parentId])}/${f.name}`; + })(folder) + : "/" + })); + + return [env.slug, { ...env, folders: foldersWithPath }]; + }) + ); + + return environmentFolders; + }; + return { createFolder, updateFolder, @@ -589,6 +634,7 @@ export const secretFolderServiceFactory = ({ getFolderById, getProjectFolderCount, getFoldersMultiEnv, - getFoldersDeepByEnvs + getFoldersDeepByEnvs, + getProjectEnvironmentsFolders }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index f42f70b78..6a5c097ed 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { z } from "zod"; import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; +import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; @@ -20,7 +21,6 @@ import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge- import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; -import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; type TPartialSecret = Pick; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8fdfeea1f..a7e9ca7e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@casl/ability": "^6.7.2", "@casl/react": "^4.0.0", + "@dagrejs/dagre": "^1.1.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -47,8 +48,10 @@ "@tanstack/react-router": "^1.95.1", "@tanstack/virtual-file-routes": "^1.87.6", "@tanstack/zod-adapter": "^1.91.0", + "@types/dagre": "^0.7.52", "@types/nprogress": "^0.2.3", "@ucast/mongo2js": "^1.3.4", + "@xyflow/react": "^12.4.4", "argon2-browser": "^1.18.0", "axios": "^1.7.9", "classnames": "^2.5.1", @@ -507,6 +510,24 @@ "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", + "integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@date-fns/tz": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", @@ -3955,6 +3976,61 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.52", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.52.tgz", + "integrity": "sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4382,6 +4458,64 @@ "vite": "^4 || ^5 || ^6" } }, + "node_modules/@xyflow/react": { + "version": "12.4.4", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.4.tgz", + "integrity": "sha512-9RZ9dgKZNJOlbrXXST5HPb5TcXPOIDGondjwcjDro44OQRPl1E0ZRPTeWPGaQtVjbg4WpR4BUYwOeshNI2TuVg==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.52", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", + "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.52", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.52.tgz", + "integrity": "sha512-pJBMaoh/GEebIABWEIxAai0yf57dm+kH7J/Br+LnLFPuJL87Fhcmm4KFWd/bCUy/kCWUg+2/yFAGY0AUHRPOnQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -5456,6 +5590,12 @@ "node": ">= 0.10" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -5808,6 +5948,111 @@ "url": "https://polar.sh/cva" } }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index d62d11242..95ad59c7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@casl/ability": "^6.7.2", "@casl/react": "^4.0.0", + "@dagrejs/dagre": "^1.1.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -51,8 +52,10 @@ "@tanstack/react-router": "^1.95.1", "@tanstack/virtual-file-routes": "^1.87.6", "@tanstack/zod-adapter": "^1.91.0", + "@types/dagre": "^0.7.52", "@types/nprogress": "^0.2.3", "@ucast/mongo2js": "^1.3.4", + "@xyflow/react": "^12.4.4", "argon2-browser": "^1.18.0", "axios": "^1.7.9", "classnames": "^2.5.1", diff --git a/frontend/src/components/permissions/AccessTree/AccessTree.tsx b/frontend/src/components/permissions/AccessTree/AccessTree.tsx new file mode 100644 index 000000000..adc2d8868 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/AccessTree.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect } from "react"; +import { MongoAbility, MongoQuery } from "@casl/ability"; +import { + faArrowUpRightFromSquare, + faUpRightAndDownLeftFromCenter, + faWindowRestore +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + Background, + BackgroundVariant, + ConnectionLineType, + Controls, + Node, + NodeMouseHandler, + Panel, + ReactFlow, + ReactFlowProvider, + useReactFlow +} from "@xyflow/react"; +import { twMerge } from "tailwind-merge"; + +import { Button, IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; + +import { AccessTreeErrorBoundary, AccessTreeProvider, PermissionSimulation } from "./components"; +import { BasePermissionEdge } from "./edges"; +import { useAccessTree } from "./hooks"; +import { FolderNode, RoleNode } from "./nodes"; +import { ViewMode } from "./types"; + +import "@xyflow/react/dist/style.css"; + +export type AccessTreeProps = { + permissions: MongoAbility; +}; + +const EdgeTypes = { base: BasePermissionEdge }; + +const NodeTypes = { role: RoleNode, folder: FolderNode }; + +const AccessTreeContent = ({ permissions }: AccessTreeProps) => { + const accessTreeData = useAccessTree(permissions); + const { edges, nodes, isLoading, viewMode, setViewMode } = accessTreeData; + + const { fitView, getViewport, setCenter } = useReactFlow(); + + const onNodeClick: NodeMouseHandler = useCallback( + (_, node) => { + setCenter( + node.position.x + (node.width ? node.width / 2 : 0), + node.position.y + (node.height ? node.height / 2 + 50 : 50), + { duration: 1000, zoom: 1 } + ); + }, + [setCenter] + ); + + useEffect(() => { + setTimeout(() => { + fitView({ + padding: 0.2, + duration: 1000, + maxZoom: 1 + }); + }, 5); + }, [fitView, nodes, edges, getViewport()]); + + const handleToggleModalView = () => + setViewMode((prev) => (prev === ViewMode.Modal ? ViewMode.Docked : ViewMode.Modal)); + + const handleToggleUndockedView = () => + setViewMode((prev) => (prev === ViewMode.Undocked ? ViewMode.Docked : ViewMode.Undocked)); + + const undockButtonLabel = `${viewMode === ViewMode.Undocked ? "Dock" : "Undock"} View`; + const windowButtonLabel = `${viewMode === ViewMode.Modal ? "Dock" : "Expand"} View`; + + return ( +
+
+ {viewMode === ViewMode.Docked && ( +
+
+

Access Tree

+

+ Visual access policies for the configured role. +

+
+
+ + +
+
+ )} +
+
+ + {isLoading && ( + + + + )} + {viewMode !== ViewMode.Docked && ( + + + + + + + + + + + + + )} + + + + +
+
+
+
+ ); +}; + +export const AccessTree = (props: AccessTreeProps) => { + return ( + + + + + + + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx b/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx new file mode 100644 index 000000000..fbac4a85e --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx @@ -0,0 +1,51 @@ +import React, { + createContext, + Dispatch, + ReactNode, + SetStateAction, + useContext, + useMemo, + useState +} from "react"; + +import { ViewMode } from "../types"; + +export interface AccessTreeContextProps { + secretName: string; + setSecretName: Dispatch>; + viewMode: ViewMode; + setViewMode: Dispatch>; +} + +const AccessTreeContext = createContext(undefined); + +interface AccessTreeProviderProps { + children: ReactNode; +} + +export const AccessTreeProvider: React.FC = ({ children }) => { + const [secretName, setSecretName] = useState("*"); + const [viewMode, setViewMode] = useState(ViewMode.Docked); + + const value = useMemo( + () => ({ + secretName, + setSecretName, + viewMode, + setViewMode + }), + [secretName, setSecretName, viewMode, setViewMode] + ); + + return {children}; +}; + +export const useAccessTreeContext = (): AccessTreeContextProps => { + const context = useContext(AccessTreeContext); + + if (!context) { + throw new Error("useAccessTreeContext must be used within a AccessTreeProvider"); + } + + return context; +}; diff --git a/frontend/src/components/permissions/AccessTree/components/AccessTreeErrorBoundary.tsx b/frontend/src/components/permissions/AccessTree/components/AccessTreeErrorBoundary.tsx new file mode 100644 index 000000000..c30eb09cb --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/components/AccessTreeErrorBoundary.tsx @@ -0,0 +1,105 @@ +import React, { ErrorInfo, ReactNode } from "react"; +import { MongoAbility, MongoQuery } from "@casl/ability"; +import { faCheck, faCopy, faExclamationTriangle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton } from "@app/components/v2"; +import { SessionStorageKeys } from "@app/const"; +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { useTimedReset } from "@app/hooks"; + +interface ErrorBoundaryProps { + children: ReactNode; + permissions: MongoAbility; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +const ErrorDisplay = ({ + error, + permissions +}: { + error: Error | null; + permissions: MongoAbility; +}) => { + const display = JSON.stringify({ errorMessage: error?.message, permissions }, null, 2); + + const [isCopied, , setIsCopied] = useTimedReset({ + initialState: false + }); + + const copyToClipboard = () => { + navigator.clipboard.writeText(display); + setIsCopied(true); + sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN); + }; + + return ( +
+
+ +

+ Error displaying access tree. Please contact{" "} + + support@infisical.com + {" "} + with the following information. +

+
+
+
+          {display}
+        
+ + + +
+
+ ); +}; + +class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null + }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error("Error caught by ErrorBoundary:", error, errorInfo, this.props); + } + + render(): ReactNode { + const { hasError, error } = this.state; + const { children, permissions } = this.props; + + if (hasError) { + return ; + } + return children; + } +} + +export const AccessTreeErrorBoundary = ({ children, permissions }: ErrorBoundaryProps) => { + return {children}; +}; diff --git a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx new file mode 100644 index 000000000..8bc3b1b9d --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx @@ -0,0 +1,137 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Panel } from "@xyflow/react"; + +import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; + +import { ViewMode } from "../types"; + +type TProps = { + secretName: string; + setSecretName: Dispatch>; + viewMode: ViewMode; + setViewMode: Dispatch>; + setEnvironment: Dispatch>; + environment: string; + subject: ProjectPermissionSub; + setSubject: Dispatch>; + environments: { name: string; slug: string }[]; +}; + +export const PermissionSimulation = ({ + setEnvironment, + environment, + subject, + setSubject, + environments, + setViewMode, + viewMode, + secretName, + setSecretName +}: TProps) => { + const [expand, setExpand] = useState(false); + + const handlePermissionSimulation = () => { + setExpand(true); + setViewMode(ViewMode.Modal); + }; + + if (viewMode !== ViewMode.Modal) + return ( + + + + ); + + return ( + +
+
+
+ Permission Simulation + { + e.stopPropagation(); + setExpand((prev) => !prev); + }} + > + + +
+ {expand && ( +

+ Evaluate conditional policies to see what permissions will be granted given a secret + name or tags +

+ )} +
+ {expand && ( + <> +
+ + +
+
+ + +
+ {subject === ProjectPermissionSub.Secrets && ( +
+ + setSecretName(e.target.value)} /> +
+ )} + + )} +
+
+ ); +}; diff --git a/frontend/src/components/permissions/AccessTree/components/index.ts b/frontend/src/components/permissions/AccessTree/components/index.ts new file mode 100644 index 000000000..cce3f73c4 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/components/index.ts @@ -0,0 +1,3 @@ +export * from "./AccessTreeContext"; +export * from "./AccessTreeErrorBoundary"; +export * from "./PermissionSimulation"; diff --git a/frontend/src/components/permissions/AccessTree/edges/BasePermissionEdge.tsx b/frontend/src/components/permissions/AccessTree/edges/BasePermissionEdge.tsx new file mode 100644 index 000000000..ee8b4b531 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/edges/BasePermissionEdge.tsx @@ -0,0 +1,34 @@ +import { BaseEdge, BaseEdgeProps, EdgeProps, getSmoothStepPath } from "@xyflow/react"; + +export const BasePermissionEdge = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + markerStart, + markerEnd, + style +}: Omit & EdgeProps) => { + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY + }); + + return ( + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/edges/index.ts b/frontend/src/components/permissions/AccessTree/edges/index.ts new file mode 100644 index 000000000..566a0f3ee --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/edges/index.ts @@ -0,0 +1 @@ +export * from "./BasePermissionEdge"; diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts new file mode 100644 index 000000000..7de33cb14 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { MongoAbility, MongoQuery } from "@casl/ability"; +import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; + +import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; + +import { useAccessTreeContext } from "../components"; +import { PermissionAccess } from "../types"; +import { + createBaseEdge, + createFolderNode, + createRoleNode, + getSubjectActionRuleMap, + positionElements +} from "../utils"; + +export const useAccessTree = (permissions: MongoAbility) => { + const { currentWorkspace } = useWorkspace(); + const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); + const [nodes, setNodes] = useNodesState([]); + const [edges, setEdges] = useEdgesState([]); + const [subject, setSubject] = useState(ProjectPermissionSub.Secrets); + const [environment, setEnvironment] = useState(currentWorkspace.environments[0].slug); + const { data: environmentsFolders, isPending } = useListProjectEnvironmentsFolders( + currentWorkspace.id + ); + + useEffect(() => { + if (!environmentsFolders || !permissions) return; + + const { folders, name } = environmentsFolders[environment]; + + const roleNode = createRoleNode({ + subject, + environment: name + }); + + const actionRuleMap = getSubjectActionRuleMap(subject, permissions); + + const folderNodes = folders.map((folder) => + createFolderNode({ + folder, + permissions, + environment, + subject, + secretName, + actionRuleMap + }) + ); + + const folderEdges = folderNodes.map(({ data: folder }) => { + const actions = Object.values(folder.actions); + + let access: PermissionAccess; + if (Object.values(actions).some((action) => action === PermissionAccess.Full)) { + access = PermissionAccess.Full; + } else if (Object.values(actions).some((action) => action === PermissionAccess.Partial)) { + access = PermissionAccess.Partial; + } else { + access = PermissionAccess.None; + } + + return createBaseEdge({ + source: folder.parentId ?? roleNode.id, + target: folder.id, + access + }); + }); + + const init = positionElements([roleNode, ...folderNodes], [...folderEdges]); + setNodes(init.nodes); + setEdges(init.edges); + }, [permissions, environmentsFolders, environment, subject, secretName, setNodes, setEdges]); + + return { + nodes, + edges, + subject, + environment, + setEnvironment, + setSubject, + isLoading: isPending, + environments: currentWorkspace.environments, + secretName, + setSecretName, + viewMode, + setViewMode + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/index.ts b/frontend/src/components/permissions/AccessTree/index.ts new file mode 100644 index 000000000..b04249099 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/index.ts @@ -0,0 +1 @@ +export * from "./AccessTree"; diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/FolderNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/FolderNode.tsx new file mode 100644 index 000000000..2b049fc04 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/FolderNode.tsx @@ -0,0 +1,77 @@ +import { + faCheckCircle, + faCircleMinus, + faCircleXmark, + faFolder +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Handle, NodeProps, Position } from "@xyflow/react"; + +import { Tooltip } from "@app/components/v2"; + +import { PermissionAccess } from "../../types"; +import { createFolderNode, formatActionName } from "../../utils"; +import { FolderNodeTooltipContent } from "./components"; + +const AccessMap = { + [PermissionAccess.Full]: { className: "text-green", icon: faCheckCircle }, + [PermissionAccess.Partial]: { className: "text-yellow", icon: faCircleMinus }, + [PermissionAccess.None]: { className: "text-red", icon: faCircleXmark } +}; + +export const FolderNode = ({ + data +}: NodeProps & { data: ReturnType["data"] }) => { + const { name, actions, actionRuleMap, parentId, subject } = data; + + const hasMinimalAccess = Object.values(actions).some( + (action) => action === PermissionAccess.Full || action === PermissionAccess.Partial + ); + + return ( + <> + +
+
+ + {parentId ? `/${name}` : "/"} +
+
+ {Object.entries(actions).map(([action, access]) => { + const { className, icon } = AccessMap[access]; + + return ( + + } + > +
+ + {formatActionName(action)} +
+
+ ); + })} +
+
+ + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx new file mode 100644 index 000000000..807f7c0bf --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx @@ -0,0 +1,131 @@ +import { ReactElement } from "react"; +import { faCheckCircle, faCircleMinus, faCircleXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { NodeToolbar, Position } from "@xyflow/react"; + +import { + formatedConditionsOperatorNames, + PermissionConditionOperators +} from "@app/context/ProjectPermissionContext/types"; +import { camelCaseToSpaces } from "@app/lib/fn/string"; + +import { PermissionAccess } from "../../../types"; +import { createFolderNode, formatActionName } from "../../../utils"; + +type Props = { + action: string; + access: PermissionAccess; +} & Pick["data"], "actionRuleMap" | "subject">; + +export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => { + let component: ReactElement; + + switch (access) { + case PermissionAccess.Full: + component = ( + <> +
+ + Full {formatActionName(action)} Permissions +
+

+ Policy grants unconditional{" "} + + {formatActionName(action).toLowerCase()} + {" "} + permission for {subject.replaceAll("-", " ")} in this folder. +

+ + ); + break; + case PermissionAccess.Partial: + component = ( + <> +
+ + Conditional {formatActionName(action)} Permissions +
+

+ Policy conditionally allows{" "} + + {formatActionName(action).toLowerCase()} + {" "} + permission for {subject.replaceAll("-", " ")} in this folder. +

+
    + {actionRuleMap.map((ruleMap, index) => { + const rule = ruleMap[action]; + + if ( + !rule || + !rule.conditions || + (!rule.conditions.secretName && !rule.conditions.secretTags) + ) + return null; + + return ( +
  • + + {rule.inverted ? "Forbids" : "Allows"} + + when: + {Object.entries(rule.conditions).map(([key, condition]) => ( +
      + {Object.entries(condition as object).map(([operator, value]) => ( +
    • + + {camelCaseToSpaces(key)} + {" "} + + { + formatedConditionsOperatorNames[ + operator as PermissionConditionOperators + ] + } + {" "} + + {typeof value === "string" ? value : value.join(", ")} + + . +
    • + ))} +
    + ))} +
  • + ); + })} +
+ + ); + break; + case PermissionAccess.None: + component = ( + <> +
+ + No {formatActionName(action)} Permissions +
+

+ Policy always forbids{" "} + + {formatActionName(action).toLowerCase()} + {" "} + permission for {subject.replaceAll("-", " ")} in this folder. +

+ + ); + break; + default: + throw new Error(`Unhandled access type: ${access}`); + } + + return ( + + {component} + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/index.ts b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/index.ts new file mode 100644 index 000000000..79380317f --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/index.ts @@ -0,0 +1 @@ +export * from "./FolderNodeTooltipContent"; diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/index.ts b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/index.ts new file mode 100644 index 000000000..c1c0583b8 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/index.ts @@ -0,0 +1 @@ +export * from "./FolderNode"; diff --git a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx new file mode 100644 index 000000000..d38d896f2 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx @@ -0,0 +1,30 @@ +import { Handle, NodeProps, Position } from "@xyflow/react"; + +import { createRoleNode } from "../utils"; + +export const RoleNode = ({ + data: { subject, environment } +}: NodeProps & { data: ReturnType["data"] }) => { + return ( + <> + +
+
+ {subject.replace("-", " ")} Access +
+

{environment}

+
+
+
+ + + ); +}; diff --git a/frontend/src/components/permissions/AccessTree/nodes/index.ts b/frontend/src/components/permissions/AccessTree/nodes/index.ts new file mode 100644 index 000000000..a67b58c18 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/nodes/index.ts @@ -0,0 +1,2 @@ +export * from "./FolderNode/FolderNode"; +export * from "./RoleNode"; diff --git a/frontend/src/components/permissions/AccessTree/types/index.ts b/frontend/src/components/permissions/AccessTree/types/index.ts new file mode 100644 index 000000000..9536d7f86 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/types/index.ts @@ -0,0 +1,21 @@ +export enum PermissionAccess { + Full = "full", + Partial = "partial", + None = "None" +} + +export enum PermissionNode { + Role = "role", + Folder = "folder", + Environment = "environment" +} + +export enum PermissionEdge { + Base = "base" +} + +export enum ViewMode { + Docked = "docked", + Modal = "modal", + Undocked = "undocked" +} diff --git a/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts new file mode 100644 index 000000000..cc53360c8 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createBaseEdge.ts @@ -0,0 +1,26 @@ +import { MarkerType } from "@xyflow/react"; + +import { PermissionAccess, PermissionEdge } from "../types"; + +export const createBaseEdge = ({ + source, + target, + access +}: { + source: string; + target: string; + access: PermissionAccess; +}) => { + const color = access === PermissionAccess.None ? "#707174" : "#ccccce"; + return { + id: `e-${source}-${target}`, + source, + target, + type: PermissionEdge.Base, + markerEnd: { + type: MarkerType.ArrowClosed, + color + }, + style: { stroke: color } + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts new file mode 100644 index 000000000..3978bec31 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts @@ -0,0 +1,180 @@ +import { MongoAbility, MongoQuery, subject as abilitySubject } from "@casl/ability"; +import picomatch from "picomatch"; + +import { + ProjectPermissionActions, + ProjectPermissionDynamicSecretActions, + ProjectPermissionSet, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext"; +import { + PermissionConditionOperators, + ProjectPermissionSecretActions +} from "@app/context/ProjectPermissionContext/types"; +import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; + +import { PermissionAccess, PermissionNode } from "../types"; +import { TActionRuleMap } from "."; + +const ACTION_MAP: Record = { + [ProjectPermissionSub.Secrets]: [ + ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.ReadValue, + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Delete + ], + [ProjectPermissionSub.DynamicSecrets]: Object.values(ProjectPermissionDynamicSecretActions), + [ProjectPermissionSub.SecretFolders]: [ + ProjectPermissionSecretActions.Create, + ProjectPermissionSecretActions.Edit, + ProjectPermissionSecretActions.Delete + ] +}; + +const evaluateCondition = ( + value: string, + operator: PermissionConditionOperators, + comparison: string | string[] +) => { + switch (operator) { + case PermissionConditionOperators.$EQ: + return value === comparison; + case PermissionConditionOperators.$NEQ: + return value !== comparison; + case PermissionConditionOperators.$GLOB: + return picomatch.isMatch(value, comparison); + case PermissionConditionOperators.$IN: + return (comparison as string[]).map((v: string) => v.trim()).includes(value); + default: + throw new Error(`Unhandled operator: ${operator}`); + } +}; + +export const createFolderNode = ({ + folder, + permissions, + environment, + subject, + secretName, + actionRuleMap +}: { + folder: TSecretFolderWithPath; + permissions: MongoAbility; + environment: string; + subject: ProjectPermissionSub; + secretName: string; + actionRuleMap: TActionRuleMap; +}) => { + const actions = Object.fromEntries( + Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => { + let access: PermissionAccess; + + // wrapped in try because while editing certain conditions, if their values are empty it throws an error + try { + let hasPermission: boolean; + + const subjectFields = { + secretPath: folder.path, + environment, + secretName: secretName || "*", + secretTags: ["*"] + }; + + if ( + subject === ProjectPermissionSub.Secrets && + (action === ProjectPermissionSecretActions.ReadValue || + action === ProjectPermissionSecretActions.DescribeSecret) + ) { + hasPermission = hasSecretReadValueOrDescribePermission( + permissions, + action, + subjectFields + ); + } else { + hasPermission = permissions.can( + // @ts-expect-error we are not specifying which so can't resolve if valid + action, + abilitySubject(subject, subjectFields) + ); + } + + if (hasPermission) { + // we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access + if ( + (!secretName || secretName === "*") && + actionRuleMap.some((el) => { + // we only show conditional if secretName/secretTags are present - environment and path can be directly determined + if (!el[action]?.conditions?.secretName && !el[action]?.conditions?.secretTags) + return false; + + // make sure condition applies to env + if (el[action]?.conditions?.environment) { + if ( + !Object.entries(el[action]?.conditions?.environment).every(([operator, value]) => + evaluateCondition(environment, operator as PermissionConditionOperators, value) + ) + ) { + return false; + } + } + + // and applies to path + if (el[action]?.conditions?.secretPath) { + if ( + !Object.entries(el[action]?.conditions?.secretPath).every(([operator, value]) => + evaluateCondition(folder.path, operator as PermissionConditionOperators, value) + ) + ) { + return false; + } + } + + return true; + }) + ) { + access = PermissionAccess.Partial; + } else { + access = PermissionAccess.Full; + } + } else { + access = PermissionAccess.None; + } + } catch (e) { + console.error(e); + access = PermissionAccess.None; + } + + return [action, access]; + }) + ); + + let height = 84; + + switch (subject) { + case ProjectPermissionSub.DynamicSecrets: + height = 130; + break; + case ProjectPermissionSub.Secrets: + height = 85; + break; + default: + height = 64; + } + + return { + type: PermissionNode.Folder, + id: folder.id, + data: { + ...folder, + actions, + environment, + actionRuleMap, + subject + }, + position: { x: 0, y: 0 }, + width: 264, + height + }; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts new file mode 100644 index 000000000..354a69482 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/createRoleNode.ts @@ -0,0 +1,19 @@ +import { PermissionNode } from "../types"; + +export const createRoleNode = ({ + subject, + environment +}: { + subject: string; + environment: string; +}) => ({ + id: `role-${subject}-${environment}`, + position: { x: 0, y: 0 }, + data: { + subject, + environment + }, + type: PermissionNode.Role, + height: 48, + width: 264 +}); diff --git a/frontend/src/components/permissions/AccessTree/utils/formatActionName.ts b/frontend/src/components/permissions/AccessTree/utils/formatActionName.ts new file mode 100644 index 000000000..c89adea8b --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/formatActionName.ts @@ -0,0 +1,3 @@ +import { camelCaseToSpaces } from "@app/lib/fn/string"; + +export const formatActionName = (action: string) => camelCaseToSpaces(action.replaceAll("-", " ")); diff --git a/frontend/src/components/permissions/AccessTree/utils/getActionRuleMap.ts b/frontend/src/components/permissions/AccessTree/utils/getActionRuleMap.ts new file mode 100644 index 000000000..40b723d22 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/getActionRuleMap.ts @@ -0,0 +1,27 @@ +import { MongoAbility, MongoQuery } from "@casl/ability"; + +import { ProjectPermissionSet, ProjectPermissionSub } from "@app/context/ProjectPermissionContext"; + +export type TActionRuleMap = ReturnType; + +export const getSubjectActionRuleMap = ( + subject: ProjectPermissionSub, + permissions: MongoAbility +) => { + const rules = permissions.rules.filter((rule) => { + const ruleSubject = typeof rule.subject === "string" ? rule.subject : rule.subject[0]; + + return ruleSubject === subject; + }); + + const actionRuleMap: Record[] = []; + rules.forEach((rule) => { + if (typeof rule.action === "string") { + actionRuleMap.push({ [rule.action]: rule }); + } else { + actionRuleMap.push(Object.fromEntries(rule.action.map((action) => [action, rule]))); + } + }); + + return actionRuleMap; +}; diff --git a/frontend/src/components/permissions/AccessTree/utils/index.ts b/frontend/src/components/permissions/AccessTree/utils/index.ts new file mode 100644 index 000000000..88e8fcceb --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/index.ts @@ -0,0 +1,6 @@ +export * from "./createBaseEdge"; +export * from "./createFolderNode"; +export * from "./createRoleNode"; +export * from "./formatActionName"; +export * from "./getActionRuleMap"; +export * from "./positionElements"; diff --git a/frontend/src/components/permissions/AccessTree/utils/positionElements.ts b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts new file mode 100644 index 000000000..523b402d1 --- /dev/null +++ b/frontend/src/components/permissions/AccessTree/utils/positionElements.ts @@ -0,0 +1,28 @@ +import Dagre from "@dagrejs/dagre"; +import { Edge, Node } from "@xyflow/react"; + +export const positionElements = (nodes: Node[], edges: Edge[]) => { + const dagre = new Dagre.graphlib.Graph({ directed: true }) + .setDefaultEdgeLabel(() => ({})) + .setGraph({ rankdir: "TB" }); + + edges.forEach((edge) => dagre.setEdge(edge.source, edge.target)); + nodes.forEach((node) => dagre.setNode(node.id, node)); + + Dagre.layout(dagre, {}); + + return { + nodes: nodes.map((node) => { + const { x, y } = dagre.node(node.id); + + return { + ...node, + position: { + x: x - (node.width ? node.width / 2 : 0), + y: y - (node.height ? node.height / 2 : 0) + } + }; + }), + edges + }; +}; diff --git a/frontend/src/components/permissions/index.tsx b/frontend/src/components/permissions/index.tsx index 5103b0f73..c40079a4f 100644 --- a/frontend/src/components/permissions/index.tsx +++ b/frontend/src/components/permissions/index.tsx @@ -1,3 +1,4 @@ +export * from "./AccessTree"; export { GlobPermissionInfo } from "./GlobPermissionInfo"; export { OrgPermissionCan } from "./OrgPermissionCan"; export { PermissionDeniedBanner } from "./PermissionDeniedBanner"; diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 7259c8157..e0dc90186 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -59,7 +59,9 @@ export const Select = forwardRef( >
{props.icon && } - +
+ +
@@ -122,7 +124,7 @@ export const SelectItem = forwardRef( { staleTime: Infinity, select: (data) => { const rule = unpackRules>>(data.permissions); - const negatedRules = groupBy( - rule.filter((i) => i.inverted && i.conditions), - (i) => `${i.subject}-${JSON.stringify(i.conditions)}` - ); - const ability = createMongoAbility(rule, { - // this allows in frontend to skip some rules using * - conditionsMatcher: (rules) => { - return (entity) => { - // skip validation if its negated rules - const isNegatedRule = - // eslint-disable-next-line no-underscore-dangle - negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`]; - if (isNegatedRule) { - const baseMatcher = conditionsMatcher(rules); - return baseMatcher(entity); - } - - const rulesStrippedOfWildcard = omit( - rules, - Object.keys(entity).filter((el) => entity[el]?.includes("*")) - ); - const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard); - return baseMatcher(entity); - }; - } - }); - + const ability = evaluatePermissionsAbility(rule); return { permission: ability, membership: { diff --git a/frontend/src/helpers/permissions.ts b/frontend/src/helpers/permissions.ts new file mode 100644 index 000000000..2f07ee05c --- /dev/null +++ b/frontend/src/helpers/permissions.ts @@ -0,0 +1,39 @@ +import { createMongoAbility, MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability"; + +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { conditionsMatcher } from "@app/hooks/api/roles/queries"; +import { groupBy } from "@app/lib/fn/array"; +import { omit } from "@app/lib/fn/object"; + +export const evaluatePermissionsAbility = ( + rule: RawRuleOf>[] +) => { + const negatedRules = groupBy( + rule.filter((i) => i.inverted && i.conditions), + (i) => `${i.subject}-${JSON.stringify(i.conditions)}` + ); + const ability = createMongoAbility(rule, { + // this allows in frontend to skip some rules using * + conditionsMatcher: (rules) => { + return (entity) => { + // skip validation if its negated rules + const isNegatedRule = + // eslint-disable-next-line no-underscore-dangle + negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`]; + if (isNegatedRule) { + const baseMatcher = conditionsMatcher(rules); + return baseMatcher(entity); + } + + const rulesStrippedOfWildcard = omit( + rules, + Object.keys(entity).filter((el) => entity[el]?.includes("*")) + ); + const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard); + return baseMatcher(entity); + }; + } + }); + + return ability; +}; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index 4d9f05bb1..5eb6c6732 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,10 +1,10 @@ export { useAdminDeleteUser, + useAdminGrantServerAdminAccess, useCreateAdminUser, useUpdateAdminSlackConfig, useUpdateServerConfig, - useUpdateServerEncryptionStrategy, - useAdminGrantServerAdminAccess + useUpdateServerEncryptionStrategy } from "./mutation"; export { useAdminGetUsers, diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 10f835da8..afc71434d 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -16,6 +16,7 @@ import { TDeleteFolderDTO, TGetFoldersByEnvDTO, TGetProjectFoldersDTO, + TProjectEnvironmentsFolders, TSecretFolder, TUpdateFolderBatchDTO, TUpdateFolderDTO @@ -23,7 +24,9 @@ import { export const folderQueryKeys = { getSecretFolders: ({ projectId, environment, path }: TGetProjectFoldersDTO) => - ["secret-folders", { projectId, environment, path }] as const + ["secret-folders", { projectId, environment, path }] as const, + getProjectEnvironmentsFolders: (projectId: string) => + ["secret-folders", "environment", projectId] as const }; const fetchProjectFolders = async (workspaceId: string, environment: string, path = "/") => { @@ -37,6 +40,29 @@ const fetchProjectFolders = async (workspaceId: string, environment: string, pat return data.folders; }; +export const useListProjectEnvironmentsFolders = ( + projectId: string, + options?: Omit< + UseQueryOptions< + TProjectEnvironmentsFolders, + unknown, + TProjectEnvironmentsFolders, + ReturnType + >, + "queryKey" | "queryFn" + > +) => + useQuery({ + queryKey: folderQueryKeys.getProjectEnvironmentsFolders(projectId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/workspace/${projectId}/folders/project-environments` + ); + return data; + }, + ...options + }); + export const useGetProjectFolders = ({ projectId, environment, diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index 454e159e1..33653ff72 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -1,3 +1,5 @@ +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + export enum ReservedFolders { SecretReplication = "__reserve_replication_" } @@ -6,6 +8,13 @@ export type TSecretFolder = { id: string; name: string; description?: string; + parentId?: string | null; +}; + +export type TSecretFolderWithPath = TSecretFolder & { path: string }; + +export type TProjectEnvironmentsFolders = { + [key: string]: WorkspaceEnv & { folders: TSecretFolderWithPath[] }; }; export type TGetProjectFoldersDTO = { diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index a3e5f8faf..89fc679c4 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -10,13 +10,16 @@ type FolderNameAndDescription = { export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => { const folderNamesAndDescriptions = useMemo(() => { const namesAndDescriptions = new Map(); - + folders?.forEach((folder) => { if (!namesAndDescriptions.has(folder.name)) { - namesAndDescriptions.set(folder.name, { name: folder.name, description: folder.description }); + namesAndDescriptions.set(folder.name, { + name: folder.name, + description: folder.description + }); } }); - + return Array.from(namesAndDescriptions.values()); }, [folders]); diff --git a/frontend/src/layouts/OrganizationLayout/components/MenuIconButton/MenuIconButton.tsx b/frontend/src/layouts/OrganizationLayout/components/MenuIconButton/MenuIconButton.tsx index 7da236ef4..99d24ece6 100644 --- a/frontend/src/layouts/OrganizationLayout/components/MenuIconButton/MenuIconButton.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/MenuIconButton/MenuIconButton.tsx @@ -25,8 +25,8 @@ export const MenuIconButton = ({ type="button" role="menuitem" className={twMerge( - "group relative flex w-full cursor-pointer flex-col items-center justify-center rounded my-1 p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700", - isSelected && "bg-bunker-800 hover:bg-mineshaft-600 rounded-none", + "group relative my-1 flex w-full cursor-pointer flex-col items-center justify-center rounded p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700", + isSelected && "rounded-none bg-bunker-800 hover:bg-mineshaft-600", isDisabled && "cursor-not-allowed hover:bg-transparent", className )} diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx index a931cb606..4b3f2b270 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsTable.tsx @@ -28,7 +28,14 @@ type Props = { const AUDIT_LOG_LIMIT = 15; -const TABLE_HEADERS = ["Timestamp (MM/DD/YYYY)", "Event", "Project", "Actor", "Source", "Metadata"] as const; +const TABLE_HEADERS = [ + "Timestamp (MM/DD/YYYY)", + "Event", + "Project", + "Actor", + "Source", + "Metadata" +] as const; export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number]; export const LogsTable = ({ diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index 716b6ba5e..d44927edf 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -1,10 +1,13 @@ +import { useMemo } from "react"; import { FormProvider, useForm } from "react-hook-form"; +import { MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability"; import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; +import { AccessTree } from "@app/components/permissions"; import { Button, DropdownMenu, @@ -13,6 +16,8 @@ import { DropdownMenuTrigger } from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; +import { evaluatePermissionsAbility } from "@app/helpers/permissions"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { GeneralPermissionConditions } from "./GeneralPermissionConditions"; @@ -115,94 +120,109 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { } }; + const permissions = form.watch("permissions"); + + const formattedPermissions = useMemo( + () => + evaluatePermissionsAbility( + formRolePermission2API(permissions) as RawRuleOf< + MongoAbility + >[] + ), + [JSON.stringify(permissions)] + ); + return ( -
- -
-

Policies

-
- {isCustomRole && ( - <> - {isDirty && ( - - )} -
- - - - - - - {Object.keys(PROJECT_PERMISSION_OBJECT) - .sort((a, b) => - PROJECT_PERMISSION_OBJECT[ - a as keyof typeof PROJECT_PERMISSION_OBJECT - ].title - .toLowerCase() - .localeCompare( - PROJECT_PERMISSION_OBJECT[ - b as keyof typeof PROJECT_PERMISSION_OBJECT - ].title.toLowerCase() - ) - ) - .map((subject) => ( - onNewPolicy(subject as ProjectPermissionSub)} - > - {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} - - ))} - - -
- - )} +
+ + + +
+

Policies

+
+ {isCustomRole && ( + <> + {isDirty && ( + + )} +
+ + + + + + + {Object.keys(PROJECT_PERMISSION_OBJECT) + .sort((a, b) => + PROJECT_PERMISSION_OBJECT[ + a as keyof typeof PROJECT_PERMISSION_OBJECT + ].title + .toLowerCase() + .localeCompare( + PROJECT_PERMISSION_OBJECT[ + b as keyof typeof PROJECT_PERMISSION_OBJECT + ].title.toLowerCase() + ) + ) + .map((subject) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + + +
+ + )} +
-
-
- {!isPending && } - {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( - - {renderConditionalComponents(subject, isDisabled)} - - ))} -
- - +
+ {!isPending && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {renderConditionalComponents(subject, isDisabled)} + + ))} +
+ + +
); }; diff --git a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx index 25866778e..8e3d34b5a 100644 --- a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx +++ b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx @@ -120,7 +120,14 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -155,7 +162,16 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { errorText={error?.message} isOptional > - + )} /> diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index f96aabf53..78a2380d0 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -228,7 +228,8 @@ export const OverviewPage = () => { setPage }); - const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders); + const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = + useFolderOverview(folders); const { dynamicSecretNames, isDynamicSecretPresentInEnv } = useDynamicSecretOverview(dynamicSecrets); @@ -251,7 +252,7 @@ export const OverviewPage = () => { "updateFolder" ] as const); - const handleFolderCreate = async (folderName: string, description: string | null) => { + const handleFolderCreate = async (folderName: string, description: string | null) => { const promises = userAvailableEnvs.map((env) => { const environment = env.slug; return createFolder({ @@ -1029,7 +1030,7 @@ export const OverviewPage = () => { )} {!isOverviewLoading && visibleEnvs.length > 0 && ( <> - {folderNamesAndDescriptions.map(({name: folderName, description}, index) => ( + {folderNamesAndDescriptions.map(({ name: folderName, description }, index) => ( { )?.name} - defaultDescription={(popUp.updateFolder?.data as Pick)?.description} + defaultDescription={ + (popUp.updateFolder?.data as Pick)?.description + } onUpdateFolder={handleFolderUpdate} showDescriptionOverwriteWarning /> diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 4847feb69..7248bc19f 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -16,10 +16,10 @@ import { useProjectPermission, useWorkspace } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { getKeyValue } from "@app/helpers/parseEnvVar"; import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api"; import { SecretType } from "@app/hooks/api/types"; -import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; const typeSchema = z .object({ diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx index 063b2d08b..4af92b210 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx @@ -23,22 +23,26 @@ import { useWorkspace } from "@app/context"; import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types"; -const passwordRequirementsSchema = z.object({ - length: z.number().min(1).max(250), - required: z.object({ - lowercase: z.number().min(0), - uppercase: z.number().min(0), - digits: z.number().min(0), - symbols: z.number().min(0) - }).refine((data) => { - const total = Object.values(data).reduce((sum, count) => sum + count, 0); - return total <= 250; - }, "Sum of required characters cannot exceed 250"), - allowedSymbols: z.string().optional() -}).refine((data) => { - const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); - return total <= data.length; -}, "Sum of required characters cannot exceed the total length"); +const passwordRequirementsSchema = z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length"); const formSchema = z.object({ provider: z.object({ @@ -166,7 +170,7 @@ export const SqlDatabaseInputForm = ({ digits: 1, symbols: 0 }, - allowedSymbols: '-_.~!*' + allowedSymbols: "-_.~!*" } } } @@ -205,7 +209,7 @@ export const SqlDatabaseInputForm = ({ setValue("provider.renewStatement", sqlStatment.renewStatement); setValue("provider.revocationStatement", sqlStatment.revocationStatement); setValue("provider.port", getDefaultPort(type)); - + // Update password requirements based on provider const length = type === SqlProviders.Oracle ? 30 : 48; setValue("provider.passwordRequirements.length", length); @@ -424,7 +428,9 @@ export const SqlDatabaseInputForm = ({ /> - Creation, Revocation & Renew Statements (optional) + + Creation, Revocation & Renew Statements (optional) +
Customize SQL statements for managing database user lifecycle @@ -508,10 +514,10 @@ export const SqlDatabaseInputForm = ({ isError={Boolean(error)} errorText={error?.message} > - field.onChange(Number(e.target.value))} /> @@ -519,17 +525,20 @@ export const SqlDatabaseInputForm = ({ )} />
- +

Minimum Required Character Counts

{(() => { - const total = Object.values(watch("provider.passwordRequirements.required") || {}).reduce((sum, count) => sum + Number(count || 0), 0); + const total = Object.values( + watch("provider.passwordRequirements.required") || {} + ).reduce((sum, count) => sum + Number(count || 0), 0); const length = watch("provider.passwordRequirements.length") || 0; const isError = total > length; return ( - Total required characters: {total} {isError ? `(exceeds length of ${length})` : ""} + Total required characters: {total}{" "} + {isError ? `(exceeds length of ${length})` : ""} ); })()} @@ -546,9 +555,9 @@ export const SqlDatabaseInputForm = ({ errorText={error?.message} helperText="Minimum number of lowercase letters" > - field.onChange(Number(e.target.value))} /> @@ -566,9 +575,9 @@ export const SqlDatabaseInputForm = ({ errorText={error?.message} helperText="Minimum number of uppercase letters" > - field.onChange(Number(e.target.value))} /> @@ -586,9 +595,9 @@ export const SqlDatabaseInputForm = ({ errorText={error?.message} helperText="Minimum number of digits" > - field.onChange(Number(e.target.value))} /> @@ -606,9 +615,9 @@ export const SqlDatabaseInputForm = ({ errorText={error?.message} helperText="Minimum number of symbols" > - field.onChange(Number(e.target.value))} /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx index 78d075b4f..885838498 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx @@ -15,7 +15,8 @@ type Props = { showDescriptionOverwriteWarning?: boolean; }; -const descriptionOverwriteWarningMessage = "Warning: Any changes made here will overwrite any custom edits in individual environment folders." +const descriptionOverwriteWarningMessage = + "Warning: Any changes made here will overwrite any custom edits in individual environment folders."; const formSchema = z.object({ name: z @@ -25,9 +26,7 @@ const formSchema = z.object({ /^[a-zA-Z0-9-_]+$/, "Folder name can only contain letters, numbers, dashes, and underscores" ), - description: z - .string() - .optional() + description: z.string().optional() }); type TFormData = z.infer; @@ -59,7 +58,7 @@ export const FolderForm = ({ if (textarea) { const lines = textarea.value.split("\n"); const maxDescriptionLines = 10; - + if (lines.length > maxDescriptionLines) { textarea.value = lines.slice(0, maxDescriptionLines).join("\n"); } @@ -90,30 +89,32 @@ export const FolderForm = ({ )} /> ( - -