feature: access tree

This commit is contained in:
Scott Wilson
2025-03-11 22:00:25 -07:00
parent 9cf5908cc1
commit 1d186b1950
51 changed files with 1889 additions and 262 deletions

View File

@@ -21,7 +21,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
secretVersioning: true,
pitRecovery: false,
ipAllowlisting: false,
rbac: false,
rbac: true,
customRateLimits: false,
customAlerts: false,
secretAccessInsights: false,

View File

@@ -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<TPermissionServiceFactory, "getProjectPermission">;

View File

@@ -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;
}
});
};

View File

@@ -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<TPermissionServiceFactory, "getProjectPermission">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
folderDAL: TSecretFolderDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs" | "find">;
folderVersionDAL: TSecretFolderVersionDALFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
};
@@ -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
};
};

View File

@@ -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<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote">;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<ProjectPermissionSet, MongoQuery>;
};
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<Node> = 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 (
<div
className={twMerge(
"w-full",
viewMode === ViewMode.Modal && "fixed inset-0 z-50 p-10",
viewMode === ViewMode.Undocked &&
"fixed bottom-4 left-20 z-50 h-[40%] w-[38%] min-w-[32rem] lg:w-[34%]"
)}
>
<div
className={twMerge(
"mb-4 h-full w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 transition-transform duration-500",
viewMode === ViewMode.Docked ? "relative p-4" : "relative p-0"
)}
>
{viewMode === ViewMode.Docked && (
<div className="mb-4 flex items-start justify-between border-b border-mineshaft-400 pb-4">
<div>
<h3 className="text-lg font-semibold text-mineshaft-100">Access Tree</h3>
<p className="text-sm leading-3 text-mineshaft-400">
Visual access policies for the configured role.
</p>
</div>
<div className="whitespace-nowrap">
<Button
variant="outline_bg"
colorSchema="secondary"
type="submit"
className="h-10 rounded-r-none bg-mineshaft-700"
leftIcon={<FontAwesomeIcon icon={faWindowRestore} />}
onClick={handleToggleUndockedView}
>
Undock
</Button>
<Button
variant="outline_bg"
colorSchema="secondary"
type="submit"
className="h-10 rounded-l-none bg-mineshaft-600"
leftIcon={<FontAwesomeIcon icon={faUpRightAndDownLeftFromCenter} />}
onClick={handleToggleModalView}
>
Expand
</Button>
</div>
</div>
)}
<div
className={twMerge(
"flex items-center space-x-4",
viewMode === ViewMode.Docked ? "h-96" : "h-full"
)}
>
<div className="h-full w-full">
<ReactFlow
className="rounded-md border border-mineshaft"
nodes={nodes}
edges={edges}
edgeTypes={EdgeTypes}
nodeTypes={NodeTypes}
fitView
onNodeClick={onNodeClick}
colorMode="dark"
nodesDraggable={false}
edgesReconnectable={false}
nodesConnectable={false}
connectionLineType={ConnectionLineType.SmoothStep}
proOptions={{
hideAttribution: false // we need pro license if we want to hide
}}
>
{isLoading && (
<Panel className="flex h-full w-full items-center justify-center">
<Spinner />
</Panel>
)}
{viewMode !== ViewMode.Docked && (
<Panel position="top-right" className="flex gap-1.5">
<Tooltip position="bottom" align="center" content={undockButtonLabel}>
<IconButton
className="mr-1 rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleUndockedView}
ariaLabel={undockButtonLabel}
>
<FontAwesomeIcon
icon={
viewMode === ViewMode.Undocked
? faArrowUpRightFromSquare
: faWindowRestore
}
/>
</IconButton>
</Tooltip>
<Tooltip align="end" position="bottom" content={windowButtonLabel}>
<IconButton
className="rounded"
colorSchema="secondary"
variant="plain"
onClick={handleToggleModalView}
ariaLabel={windowButtonLabel}
>
<FontAwesomeIcon
icon={
viewMode === ViewMode.Modal
? faArrowUpRightFromSquare
: faUpRightAndDownLeftFromCenter
}
/>
</IconButton>
</Tooltip>
</Panel>
)}
<PermissionSimulation {...accessTreeData} />
<Background color="#5d5f64" bgColor="#111419" variant={BackgroundVariant.Dots} />
<Controls position="bottom-left" />
</ReactFlow>
</div>
</div>
</div>
</div>
);
};
export const AccessTree = (props: AccessTreeProps) => {
return (
<AccessTreeErrorBoundary {...props}>
<AccessTreeProvider>
<ReactFlowProvider>
<AccessTreeContent {...props} />
</ReactFlowProvider>
</AccessTreeProvider>
</AccessTreeErrorBoundary>
);
};

View File

@@ -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<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
}
const AccessTreeContext = createContext<AccessTreeContextProps | undefined>(undefined);
interface AccessTreeProviderProps {
children: ReactNode;
}
export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children }) => {
const [secretName, setSecretName] = useState("*");
const [viewMode, setViewMode] = useState(ViewMode.Docked);
const value = useMemo(
() => ({
secretName,
setSecretName,
viewMode,
setViewMode
}),
[secretName, setSecretName, viewMode, setViewMode]
);
return <AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>;
};
export const useAccessTreeContext = (): AccessTreeContextProps => {
const context = useContext(AccessTreeContext);
if (!context) {
throw new Error("useAccessTreeContext must be used within a AccessTreeProvider");
}
return context;
};

View File

@@ -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<ProjectPermissionSet, MongoQuery>;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
const ErrorDisplay = ({
error,
permissions
}: {
error: Error | null;
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
}) => {
const display = JSON.stringify({ errorMessage: error?.message, permissions }, null, 2);
const [isCopied, , setIsCopied] = useTimedReset<boolean>({
initialState: false
});
const copyToClipboard = () => {
navigator.clipboard.writeText(display);
setIsCopied(true);
sessionStorage.removeItem(SessionStorageKeys.CLI_TERMINAL_TOKEN);
};
return (
<div className="flex h-full w-full flex-col gap-2">
<div className="flex items-center gap-2 text-mineshaft-100">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red" />
<p>
Error displaying access tree. Please contact{" "}
<a
className="inline cursor-pointer text-mineshaft-200 underline decoration-primary-500 underline-offset-4 duration-200 hover:text-mineshaft-100"
target="_blank"
rel="noopener noreferrer"
href="mailto:support@infisical.com"
>
support@infisical.com
</a>{" "}
with the following information.
</p>
</div>
<div className="relative flex flex-1 flex-col overflow-hidden">
<pre className="thin-scrollbar w-full flex-1 overflow-y-auto whitespace-pre-wrap rounded bg-mineshaft-700 p-2 text-xs text-mineshaft-100">
{display}
</pre>
<IconButton
variant="plain"
colorSchema="secondary"
className="absolute right-4 top-2"
ariaLabel="Copy secret value"
onClick={copyToClipboard}
>
<FontAwesomeIcon icon={isCopied ? faCheck : faCopy} />
</IconButton>
</div>
</div>
);
};
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
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 <ErrorDisplay error={error} permissions={permissions} />;
}
return children;
}
}
export const AccessTreeErrorBoundary = ({ children, permissions }: ErrorBoundaryProps) => {
return <ErrorBoundary permissions={permissions}>{children}</ErrorBoundary>;
};

View File

@@ -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<SetStateAction<string>>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
setEnvironment: Dispatch<SetStateAction<string>>;
environment: string;
subject: ProjectPermissionSub;
setSubject: Dispatch<SetStateAction<ProjectPermissionSub>>;
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 (
<Panel position="top-left">
<Button
size="xs"
className="mr-1 rounded"
colorSchema="secondary"
onClick={handlePermissionSimulation}
>
Permission Simulation
</Button>
</Panel>
);
return (
<Panel
onClick={handlePermissionSimulation}
position="top-left"
className={`group flex flex-col gap-2 pb-4 pr-4 ${expand ? "" : "cursor-pointer"}`}
>
<div className="flex w-[20rem] flex-col gap-1.5 rounded border border-mineshaft-600 bg-mineshaft-800 p-2 font-inter text-gray-200">
<div>
<div className="flex w-full items-center justify-between">
<span className="text-sm">Permission Simulation</span>
<IconButton
variant="plain"
ariaLabel={expand ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
setExpand((prev) => !prev);
}}
>
<FontAwesomeIcon icon={expand ? faChevronUp : faChevronDown} />
</IconButton>
</div>
{expand && (
<p className="mb-2 mt-1 text-xs text-mineshaft-400">
Evaluate conditional policies to see what permissions will be granted given a secret
name or tags
</p>
)}
</div>
{expand && (
<>
<div>
<FormLabel label="Subject" />
<Select
value={subject}
onValueChange={(value) => setSubject(value as ProjectPermissionSub)}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
>
{[
ProjectPermissionSub.Secrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.DynamicSecrets,
ProjectPermissionSub.SecretImports
].map((sub) => {
return (
<SelectItem className="capitalize" value={sub} key={sub}>
{sub.replace("-", " ")}
</SelectItem>
);
})}
</Select>
</div>
<div>
<FormLabel label="Environment" />
<Select
value={environment}
onValueChange={setEnvironment}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-[19rem]"
>
{environments.map(({ name, slug }) => {
return (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
);
})}
</Select>
</div>
{subject === ProjectPermissionSub.Secrets && (
<div>
<FormLabel label="Secret Name" />
<Input value={secretName} onChange={(e) => setSecretName(e.target.value)} />
</div>
)}
</>
)}
</div>
</Panel>
);
};

View File

@@ -0,0 +1,3 @@
export * from "./AccessTreeContext";
export * from "./AccessTreeErrorBoundary";
export * from "./PermissionSimulation";

View File

@@ -0,0 +1,34 @@
import { BaseEdge, BaseEdgeProps, EdgeProps, getSmoothStepPath } from "@xyflow/react";
export const BasePermissionEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
markerStart,
markerEnd,
style
}: Omit<BaseEdgeProps, "path"> & EdgeProps) => {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY
});
return (
<BaseEdge
id={id}
markerStart={markerStart}
markerEnd={markerEnd}
style={{
strokeDasharray: "5",
strokeWidth: 1,
stroke: "#707174",
...style
}}
path={edgePath}
/>
);
};

View File

@@ -0,0 +1 @@
export * from "./BasePermissionEdge";

View File

@@ -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<ProjectPermissionSet, MongoQuery>) => {
const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const [nodes, setNodes] = useNodesState<Node>([]);
const [edges, setEdges] = useEdgesState<Edge>([]);
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
};
};

View File

@@ -0,0 +1 @@
export * from "./AccessTree";

View File

@@ -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<typeof createFolderNode>["data"] }) => {
const { name, actions, actionRuleMap, parentId, subject } = data;
const hasMinimalAccess = Object.values(actions).some(
(action) => action === PermissionAccess.Full || action === PermissionAccess.Partial
);
return (
<>
<Handle
type="target"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div
className={`flex ${hasMinimalAccess ? "" : "opacity-40"} h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-2 py-3 font-inter shadow-lg transition-opacity duration-500`}
>
<div className="flex items-center space-x-2 text-xs text-mineshaft-100">
<FontAwesomeIcon className="mb-0.5 font-medium text-yellow" icon={faFolder} />
<span>{parentId ? `/${name}` : "/"}</span>
</div>
<div className="mt-1.5 flex w-full flex-wrap items-center justify-center gap-x-2 gap-y-1 rounded bg-mineshaft-600 px-2 py-1 text-xs">
{Object.entries(actions).map(([action, access]) => {
const { className, icon } = AccessMap[access];
return (
<Tooltip
className="hidden" // just using the tooltip to trigger node toolbar
content={
<FolderNodeTooltipContent
action={action}
access={access}
subject={subject}
actionRuleMap={actionRuleMap}
/>
}
>
<div className="flex items-center gap-1">
<FontAwesomeIcon icon={icon} className={className} size="xs" />
<span className="capitalize">{formatActionName(action)}</span>
</div>
</Tooltip>
);
})}
</div>
</div>
<Handle
type="source"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Bottom}
/>
</>
);
};

View File

@@ -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<ReturnType<typeof createFolderNode>["data"], "actionRuleMap" | "subject">;
export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => {
let component: ReactElement;
switch (access) {
case PermissionAccess.Full:
component = (
<>
<div className="flex items-center gap-1.5 capitalize text-green">
<FontAwesomeIcon icon={faCheckCircle} size="xs" />
<span>Full {formatActionName(action)} Permissions</span>
</div>
<p className="text-mineshaft-200">
Policy grants unconditional{" "}
<span className="font-medium text-mineshaft-100">
{formatActionName(action).toLowerCase()}
</span>{" "}
permission for {subject.replaceAll("-", " ")} in this folder.
</p>
</>
);
break;
case PermissionAccess.Partial:
component = (
<>
<div className="flex items-center gap-1.5 capitalize text-yellow">
<FontAwesomeIcon icon={faCircleMinus} className="text-yellow" size="xs" />
<span>Conditional {formatActionName(action)} Permissions</span>
</div>
<p className="mb-1 text-mineshaft-200">
Policy conditionally allows{" "}
<span className="font-medium text-mineshaft-100">
{formatActionName(action).toLowerCase()}
</span>{" "}
permission for {subject.replaceAll("-", " ")} in this folder.
</p>
<ul className="flex list-disc flex-col gap-2 pl-4">
{actionRuleMap.map((ruleMap, index) => {
const rule = ruleMap[action];
if (
!rule ||
!rule.conditions ||
(!rule.conditions.secretName && !rule.conditions.secretTags)
)
return null;
return (
<li key={`${action}_${index + 1}`}>
<span className={`italic ${rule.inverted ? "text-red" : "text-green"} `}>
{rule.inverted ? "Forbids" : "Allows"}
</span>
<span> when:</span>
{Object.entries(rule.conditions).map(([key, condition]) => (
<ul key={key} className="list-[square] pl-4">
{Object.entries(condition as object).map(([operator, value]) => (
<li>
<span className="font-medium capitalize text-mineshaft-100">
{camelCaseToSpaces(key)}
</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
}
</span>{" "}
<span className={rule.inverted ? "text-red" : "text-green"}>
{typeof value === "string" ? value : value.join(", ")}
</span>
.
</li>
))}
</ul>
))}
</li>
);
})}
</ul>
</>
);
break;
case PermissionAccess.None:
component = (
<>
<div className="flex items-center gap-1.5 capitalize text-red">
<FontAwesomeIcon icon={faCircleXmark} size="xs" />
<span>No {formatActionName(action)} Permissions</span>
</div>
<p className="text-mineshaft-200">
Policy always forbids{" "}
<span className="font-medium text-mineshaft-100">
{formatActionName(action).toLowerCase()}
</span>{" "}
permission for {subject.replaceAll("-", " ")} in this folder.
</p>
</>
);
break;
default:
throw new Error(`Unhandled access type: ${access}`);
}
return (
<NodeToolbar
className="rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-sm font-light text-bunker-100"
isVisible
position={Position.Bottom}
>
{component}
</NodeToolbar>
);
};

View File

@@ -0,0 +1 @@
export * from "./FolderNodeTooltipContent";

View File

@@ -0,0 +1 @@
export * from "./FolderNode";

View File

@@ -0,0 +1,30 @@
import { Handle, NodeProps, Position } from "@xyflow/react";
import { createRoleNode } from "../utils";
export const RoleNode = ({
data: { subject, environment }
}: NodeProps & { data: ReturnType<typeof createRoleNode>["data"] }) => {
return (
<>
<Handle
type="target"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Top}
/>
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-mineshaft bg-mineshaft-800 px-3 py-2 font-inter shadow-lg">
<div className="flex max-w-[14rem] flex-col items-center text-xs text-mineshaft-200">
<span className="capitalize">{subject.replace("-", " ")} Access</span>
<div className="max-w-[14rem] whitespace-nowrap text-xs text-mineshaft-300">
<p className="truncate capitalize">{environment}</p>
</div>
</div>
</div>
<Handle
type="source"
className="pointer-events-none !cursor-pointer opacity-0"
position={Position.Bottom}
/>
</>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./FolderNode/FolderNode";
export * from "./RoleNode";

View File

@@ -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"
}

View File

@@ -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 }
};
};

View File

@@ -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<string, string[] | undefined> = {
[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<ProjectPermissionSet, MongoQuery>;
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
};
};

View File

@@ -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
});

View File

@@ -0,0 +1,3 @@
import { camelCaseToSpaces } from "@app/lib/fn/string";
export const formatActionName = (action: string) => camelCaseToSpaces(action.replaceAll("-", " "));

View File

@@ -0,0 +1,27 @@
import { MongoAbility, MongoQuery } from "@casl/ability";
import { ProjectPermissionSet, ProjectPermissionSub } from "@app/context/ProjectPermissionContext";
export type TActionRuleMap = ReturnType<typeof getSubjectActionRuleMap>;
export const getSubjectActionRuleMap = (
subject: ProjectPermissionSub,
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>
) => {
const rules = permissions.rules.filter((rule) => {
const ruleSubject = typeof rule.subject === "string" ? rule.subject : rule.subject[0];
return ruleSubject === subject;
});
const actionRuleMap: Record<string, (typeof rules)[number]>[] = [];
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;
};

View File

@@ -0,0 +1,6 @@
export * from "./createBaseEdge";
export * from "./createFolderNode";
export * from "./createRoleNode";
export * from "./formatActionName";
export * from "./getActionRuleMap";
export * from "./positionElements";

View File

@@ -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
};
};

View File

@@ -1,3 +1,4 @@
export * from "./AccessTree";
export { GlobPermissionInfo } from "./GlobPermissionInfo";
export { OrgPermissionCan } from "./OrgPermissionCan";
export { PermissionDeniedBanner } from "./PermissionDeniedBanner";

View File

@@ -59,7 +59,9 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
>
<div className="flex items-center space-x-2 overflow-hidden text-ellipsis whitespace-nowrap">
{props.icon && <FontAwesomeIcon icon={props.icon} />}
<SelectPrimitive.Value placeholder={placeholder} />
<div className="flex-1 truncate">
<SelectPrimitive.Value placeholder={placeholder} />
</div>
</div>
<SelectPrimitive.Icon className="ml-3">
@@ -122,7 +124,7 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
<SelectPrimitive.Item
{...props}
className={twMerge(
"relative mb-0.5 flex cursor-pointer select-none items-center overflow-hidden text-ellipsis whitespace-nowrap rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
"relative mb-0.5 cursor-pointer select-none items-center overflow-hidden truncate rounded-md py-2 pl-10 pr-4 text-sm outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80",
isSelected && "bg-primary",
isDisabled && "cursor-not-allowed text-gray-600 opacity-80 hover:!bg-transparent",
className

View File

@@ -1,16 +1,11 @@
import { useCallback } from "react";
import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability";
import { MongoAbility, RawRuleOf } from "@casl/ability";
import { unpackRules } from "@casl/ability/extra";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import {
conditionsMatcher,
fetchUserProjectPermissions,
roleQueryKeys
} from "@app/hooks/api/roles/queries";
import { groupBy } from "@app/lib/fn/array";
import { omit } from "@app/lib/fn/object";
import { evaluatePermissionsAbility } from "@app/helpers/permissions";
import { fetchUserProjectPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries";
import { ProjectPermissionSet } from "./types";
@@ -31,33 +26,7 @@ export const useProjectPermission = () => {
staleTime: Infinity,
select: (data) => {
const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions);
const negatedRules = groupBy(
rule.filter((i) => i.inverted && i.conditions),
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
);
const ability = createMongoAbility<ProjectPermissionSet>(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: {

View File

@@ -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<MongoAbility<ProjectPermissionSet, MongoQuery>>[]
) => {
const negatedRules = groupBy(
rule.filter((i) => i.inverted && i.conditions),
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
);
const ability = createMongoAbility<ProjectPermissionSet>(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;
};

View File

@@ -1,10 +1,10 @@
export {
useAdminDeleteUser,
useAdminGrantServerAdminAccess,
useCreateAdminUser,
useUpdateAdminSlackConfig,
useUpdateServerConfig,
useUpdateServerEncryptionStrategy,
useAdminGrantServerAdminAccess
useUpdateServerEncryptionStrategy
} from "./mutation";
export {
useAdminGetUsers,

View File

@@ -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<typeof folderQueryKeys.getProjectEnvironmentsFolders>
>,
"queryKey" | "queryFn"
>
) =>
useQuery({
queryKey: folderQueryKeys.getProjectEnvironmentsFolders(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<TProjectEnvironmentsFolders>(
`/api/v1/workspace/${projectId}/folders/project-environments`
);
return data;
},
...options
});
export const useGetProjectFolders = ({
projectId,
environment,

View File

@@ -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 = {

View File

@@ -10,13 +10,16 @@ type FolderNameAndDescription = {
export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => {
const folderNamesAndDescriptions = useMemo(() => {
const namesAndDescriptions = new Map<string, FolderNameAndDescription>();
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]);

View File

@@ -25,8 +25,8 @@ export const MenuIconButton = <T extends ElementType = "button">({
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
)}

View File

@@ -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 = ({

View File

@@ -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<ProjectPermissionSet, MongoQuery>
>[]
),
[JSON.stringify(permissions)]
);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Policies</h3>
<div className="flex items-center space-x-4">
{isCustomRole && (
<>
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={() => reset()}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
New policy
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
{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) => (
<DropdownMenuItem
key={`permission-create-${subject}`}
className="py-3"
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
>
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
<div className="w-full">
<AccessTree permissions={formattedPermissions} />
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Policies</h3>
<div className="flex items-center space-x-4">
{isCustomRole && (
<>
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={() => reset()}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
New policy
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
{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) => (
<DropdownMenuItem
key={`permission-create-${subject}`}
className="py-3"
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
>
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
</div>
</div>
</div>
<div className="py-4">
{!isPending && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
</div>
</FormProvider>
</form>
<div className="py-4">
{!isPending && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
</div>
</FormProvider>
</form>
</div>
);
};

View File

@@ -120,7 +120,14 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="API Key" type="text" autoComplete="off" autoCorrect="off" spellCheck="false" />
<Input
{...field}
placeholder="API Key"
type="text"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</FormControl>
)}
/>
@@ -155,7 +162,16 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
errorText={error?.message}
isOptional
>
<Input {...field} placeholder="Password" type="password" autoComplete="new-password" autoCorrect="off" spellCheck="false" aria-autocomplete="none" data-form-type="other" />
<Input
{...field}
placeholder="Password"
type="password"
autoComplete="new-password"
autoCorrect="off"
spellCheck="false"
aria-autocomplete="none"
data-form-type="other"
/>
</FormControl>
)}
/>

View File

@@ -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) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
@@ -1161,7 +1162,9 @@ export const OverviewPage = () => {
<FolderForm
isEdit
defaultFolderName={(popUp.updateFolder?.data as Pick<TSecretFolder, "name">)?.name}
defaultDescription={(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description}
defaultDescription={
(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description
}
onUpdateFolder={handleFolderUpdate}
showDescriptionOverwriteWarning
/>

View File

@@ -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({

View File

@@ -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 = ({
/>
<Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advanced">
<AccordionTrigger>Creation, Revocation & Renew Statements (optional)</AccordionTrigger>
<AccordionTrigger>
Creation, Revocation & Renew Statements (optional)
</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Customize SQL statements for managing database user lifecycle
@@ -508,10 +514,10 @@ export const SqlDatabaseInputForm = ({
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="number"
min={1}
max={250}
<Input
type="number"
min={1}
max={250}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -519,17 +525,20 @@ export const SqlDatabaseInputForm = ({
)}
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
<div className="text-sm text-gray-500">
{(() => {
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 (
<span className={isError ? "text-red-500" : ""}>
Total required characters: {total} {isError ? `(exceeds length of ${length})` : ""}
Total required characters: {total}{" "}
{isError ? `(exceeds length of ${length})` : ""}
</span>
);
})()}
@@ -546,9 +555,9 @@ export const SqlDatabaseInputForm = ({
errorText={error?.message}
helperText="Minimum number of lowercase letters"
>
<Input
type="number"
min={0}
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -566,9 +575,9 @@ export const SqlDatabaseInputForm = ({
errorText={error?.message}
helperText="Minimum number of uppercase letters"
>
<Input
type="number"
min={0}
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -586,9 +595,9 @@ export const SqlDatabaseInputForm = ({
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -606,9 +615,9 @@ export const SqlDatabaseInputForm = ({
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>

View File

@@ -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<typeof formSchema>;
@@ -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 = ({
)}
/>
<Controller
control={control}
name="description"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Folder Description"
isError={Boolean(error)}
tooltipText={showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined}
isOptional
errorText={error?.message}
className="flex-1"
>
<TextArea
placeholder="Folder description"
{...field}
rows={3}
ref={descriptionRef}
onInput={handleInput}
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
maxLength={255}
/>
</FormControl>
)}
/>
control={control}
name="description"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Folder Description"
isError={Boolean(error)}
tooltipText={
showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined
}
isOptional
errorText={error?.message}
className="flex-1"
>
<TextArea
placeholder="Folder description"
{...field}
rows={3}
ref={descriptionRef}
onInput={handleInput}
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
maxLength={255}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"}

View File

@@ -23,22 +23,26 @@ import { useWorkspace } from "@app/context";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { SqlProviders, TDynamicSecret } 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; // Sanity check for individual validation
}, "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; // Sanity check for individual validation
}, "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({
inputs: z
@@ -108,7 +112,7 @@ export const EditDynamicSecretSqlProviderForm = ({
digits: 1,
symbols: 0
},
allowedSymbols: '-_.~!*'
allowedSymbols: "-_.~!*"
});
const {
@@ -124,8 +128,11 @@ export const EditDynamicSecretSqlProviderForm = ({
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"]),
passwordRequirements: (dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
getDefaultPasswordRequirements((dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres)
passwordRequirements:
(dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
getDefaultPasswordRequirements(
(dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres
)
}
}
});
@@ -381,7 +388,9 @@ export const EditDynamicSecretSqlProviderForm = ({
/>
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="advanced">
<AccordionTrigger>Creation, Revocation & Renew Statements (optional)</AccordionTrigger>
<AccordionTrigger>
Creation, Revocation & Renew Statements (optional)
</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Customize SQL statements for managing database user lifecycle
@@ -472,10 +481,10 @@ export const EditDynamicSecretSqlProviderForm = ({
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="number"
min={1}
max={250}
<Input
type="number"
min={1}
max={250}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -483,17 +492,20 @@ export const EditDynamicSecretSqlProviderForm = ({
)}
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
<div className="text-sm text-gray-500">
{(() => {
const total = Object.values(watch("inputs.passwordRequirements.required") || {}).reduce((sum, count) => sum + Number(count || 0), 0);
const total = Object.values(
watch("inputs.passwordRequirements.required") || {}
).reduce((sum, count) => sum + Number(count || 0), 0);
const length = watch("inputs.passwordRequirements.length") || 0;
const isError = total > length;
return (
<span className={isError ? "text-red-500" : ""}>
Total required characters: {total} {isError ? `(exceeds length of ${length})` : ""}
Total required characters: {total}{" "}
{isError ? `(exceeds length of ${length})` : ""}
</span>
);
})()}
@@ -510,9 +522,9 @@ export const EditDynamicSecretSqlProviderForm = ({
errorText={error?.message}
helperText="Minimum number of lowercase letters"
>
<Input
type="number"
min={0}
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -530,9 +542,9 @@ export const EditDynamicSecretSqlProviderForm = ({
errorText={error?.message}
helperText="Minimum number of uppercase letters"
>
<Input
type="number"
min={0}
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -550,9 +562,9 @@ export const EditDynamicSecretSqlProviderForm = ({
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -570,9 +582,9 @@ export const EditDynamicSecretSqlProviderForm = ({
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>

View File

@@ -1,17 +1,17 @@
import { subject } from "@casl/ability";
import { faClose, faFolder, faPencilSquare, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
import { FolderForm } from "../ActionBar/FolderForm";
@@ -118,16 +118,15 @@ export const FolderListView = ({
onClick={() => handleFolderClick(name)}
>
{name}
{
description &&
{description && (
<Tooltip
position="right"
className="flex items-center space-x-4 max-w-lg py-4 whitespace-pre-wrap"
className="flex max-w-lg items-center space-x-4 whitespace-pre-wrap py-4"
content={description}
>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400 ml-1" />
<FontAwesomeIcon icon={faInfoCircle} className="ml-1 text-mineshaft-400" />
</Tooltip>
}
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan

View File

@@ -62,7 +62,7 @@ export const PitDrawer = ({
<div>
{(() => {
const distance = formatDistance(new Date(createdAt), new Date());
return distance.charAt(0).toUpperCase() + distance.slice(1) + " ago";
return `${distance.charAt(0).toUpperCase() + distance.slice(1)} ago`;
})()}
</div>
<div>{getButtonLabel(i === 0 && index === 0, snapshotId === id)}</div>

View File

@@ -58,10 +58,10 @@ import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { camelCaseToSpaces } from "@app/lib/fn/string";
import { CreateReminderForm } from "./CreateReminderForm";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
import { camelCaseToSpaces } from "@app/lib/fn/string";
type Props = {
isOpen?: boolean;

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
@@ -13,7 +14,6 @@ import { Button, FormControl, Input } from "@app/components/v2";
import { useUser } from "@app/context";
import { useResetUserPasswordV2, useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries";
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
import { useNavigate } from "@tanstack/react-router";
type Errors = {
tooShort?: string;