diff --git a/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx b/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx index 2a6767396..69c469540 100644 --- a/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx +++ b/frontend/src/components/permissions/AccessTree/components/AccessTreeContext.tsx @@ -7,6 +7,7 @@ import React, { useMemo, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; import { ViewMode } from "../types"; @@ -23,8 +24,11 @@ interface AccessTreeProviderProps { children: ReactNode; } +export type AccessTreeForm = { metadata: { key: string; value: string }[] }; + export const AccessTreeProvider: React.FC = ({ children }) => { const [secretName, setSecretName] = useState(""); + const formMethods = useForm({ defaultValues: { metadata: [] } }); const [viewMode, setViewMode] = useState(ViewMode.Docked); const value = useMemo( @@ -37,7 +41,11 @@ export const AccessTreeProvider: React.FC = ({ children [secretName, setSecretName, viewMode, setViewMode] ); - return {children}; + return ( + + {children} + + ); }; export const useAccessTreeContext = (): AccessTreeContextProps => { diff --git a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx index 6e3790b3c..ee3ff1ff3 100644 --- a/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx +++ b/frontend/src/components/permissions/AccessTree/components/PermissionSimulation.tsx @@ -1,10 +1,12 @@ import { Dispatch, SetStateAction, useState } from "react"; +import { useFormContext } from "react-hook-form"; 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 { MetadataForm } from "@app/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm"; import { ViewMode } from "../types"; @@ -32,6 +34,7 @@ export const PermissionSimulation = ({ setSecretName }: TProps) => { const [expand, setExpand] = useState(false); + const { control } = useFormContext(); const handlePermissionSimulation = () => { setExpand(true); @@ -139,6 +142,11 @@ export const PermissionSimulation = ({ /> )} + {subject === ProjectPermissionSub.DynamicSecrets && ( +
+ +
+ )} )} diff --git a/frontend/src/components/permissions/AccessTree/hooks/index.ts b/frontend/src/components/permissions/AccessTree/hooks/index.ts index a03f8fa0f..64e9e04a3 100644 --- a/frontend/src/components/permissions/AccessTree/hooks/index.ts +++ b/frontend/src/components/permissions/AccessTree/hooks/index.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; import { MongoAbility, MongoQuery } from "@casl/ability"; import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; @@ -7,7 +8,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; import { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries"; import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; -import { useAccessTreeContext } from "../components"; +import { AccessTreeForm, useAccessTreeContext } from "../components"; import { PermissionAccess } from "../types"; import { createBaseEdge, @@ -36,6 +37,8 @@ export const useAccessTree = ( ) => { const { currentWorkspace } = useWorkspace(); const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); + const { control } = useFormContext(); + const metadata = useWatch({ control, name: "metadata" }); const [nodes, setNodes] = useNodesState([]); const [edges, setEdges] = useEdgesState([]); const [subject, setSubject] = useState(ProjectPermissionSub.Secrets); @@ -168,7 +171,8 @@ export const useAccessTree = ( environment, subject, secretName, - actionRuleMap + actionRuleMap, + metadata }) ); @@ -266,7 +270,8 @@ export const useAccessTree = ( subject, secretName, setNodes, - setEdges + setEdges, + metadata ]); return { diff --git a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx index f2ca6e878..f0baedc35 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/FolderNode/components/FolderNodeTooltipContent.tsx @@ -17,6 +17,27 @@ type Props = { access: PermissionAccess; } & Pick["data"], "actionRuleMap" | "subject">; +type ConditionDisplayProps = { + _key: string; + operator: string; + value: string | string[]; +}; + +const ConditionDisplay = ({ _key: key, value, operator }: ConditionDisplayProps) => { + return ( +
  • + {camelCaseToSpaces(key)}{" "} + + {formatedConditionsOperatorNames[operator as PermissionConditionOperators]} + {" "} + + {typeof value === "string" ? value : value.join(", ")} + + . +
  • + ); +}; + export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => { let component: ReactElement; @@ -56,43 +77,58 @@ export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subjec {actionRuleMap.map((ruleMap, index) => { const rule = ruleMap[action]; - if ( - !rule || - !rule.conditions || - (!rule.conditions.secretName && !rule.conditions.secretTags) - ) - return null; + if (!rule || !rule.conditions) 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 - ] + if ( + rule.conditions.secretName || + rule.conditions.secretTags || + rule.conditions.metadata + ) { + return ( +
    • + {rule.inverted ? "Forbids" : "Allows"} + when: + {Object.entries(rule.conditions).map(([key, condition]) => { + if (key.match(/secretPath|environment/)) { + return null; + } + + return ( +
        + {Object.entries(condition as object).map(([operator, value]) => { + if (operator === "$elemMatch") { + return Object.entries(value as object).map( + ([nestedKey, nestedCondition]) => + Object.entries(nestedCondition as object).map( + ([nestedOperator, nestedValue]) => ( + + ) + ) + ); } - {" "} - - {typeof value === "string" ? value : value.join(", ")} - - . - - ))} -
      - ))} -
    • - ); + + return ( + + ); + })} +
    + ); + })} +
  • + ); + } + + return null; })} diff --git a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx index f6ffe212e..c3e9f89e0 100644 --- a/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx +++ b/frontend/src/components/permissions/AccessTree/nodes/RoleNode.tsx @@ -1,5 +1,5 @@ import { Dispatch, SetStateAction } from "react"; -import { faFileImport, faFolder, faKey, faLock } from "@fortawesome/free-solid-svg-icons"; +import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Handle, NodeProps, Position } from "@xyflow/react"; @@ -12,15 +12,15 @@ import { createRoleNode } from "../utils"; const getSubjectIcon = (subject: ProjectPermissionSub) => { switch (subject) { case ProjectPermissionSub.Secrets: - return ; + return ; case ProjectPermissionSub.SecretFolders: return ; case ProjectPermissionSub.DynamicSecrets: - return ; + return ; case ProjectPermissionSub.SecretImports: - return ; + return ; default: - return ; + return ; } }; diff --git a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts index 15c64ce8a..a40398ba7 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts @@ -33,6 +33,12 @@ const ACTION_MAP: Record = { ] }; +const SUBJECT_HEIGHT_MAP: Record = { + [ProjectPermissionSub.DynamicSecrets]: 130, + [ProjectPermissionSub.Secrets]: 85, + default: 64 +}; + const evaluateCondition = ( value: string, operator: PermissionConditionOperators, @@ -52,13 +58,113 @@ const evaluateCondition = ( } }; +const doesConditionMatch = ( + conditions: Record | undefined, + value: string +): boolean => { + if (!conditions) return true; + + return Object.entries(conditions).every(([operator, comparisonValue]) => + evaluateCondition(value, operator as PermissionConditionOperators, comparisonValue) + ); +}; + +const doBaseConditionsApply = ( + ruleConditions: any, + environment: string, + folderPath: string +): boolean => { + return ( + doesConditionMatch(ruleConditions?.environment, environment) && + doesConditionMatch(ruleConditions?.secretPath, folderPath) + ); +}; + +const shouldShowConditionalAccess = ( + actionRuleMap: TActionRuleMap, + action: string, + environment: string, + folderPath: string, + conditionalFields: string[] +): boolean => { + return actionRuleMap.some((rule) => { + const ruleConditions = rule[action]?.conditions; + if (!ruleConditions) return false; + + // Check if any of the conditional fields are present + const hasConditionalField = conditionalFields.some((field) => ruleConditions[field]); + if (!hasConditionalField) return false; + + // Check if base conditions (environment and secretPath) apply + return doBaseConditionsApply(ruleConditions, environment, folderPath); + }); +}; + +const determineAccessLevel = ( + hasPermission: boolean, + subject: ProjectPermissionSub, + action: string, + actionRuleMap: TActionRuleMap, + environment: string, + folderPath: string, + secretName: string, + metadata: Array<{ key: string; value: string }> +): PermissionAccess => { + if (!hasPermission) { + return PermissionAccess.None; + } + + if (subject === ProjectPermissionSub.Secrets) { + if ( + !secretName && + shouldShowConditionalAccess(actionRuleMap, action, environment, folderPath, [ + "secretName", + "secretTags" + ]) + ) { + return PermissionAccess.Partial; + } + } else if (subject === ProjectPermissionSub.DynamicSecrets) { + if ( + !metadata.length && + shouldShowConditionalAccess(actionRuleMap, action, environment, folderPath, ["metadata"]) + ) { + return PermissionAccess.Partial; + } + } + + return PermissionAccess.Full; +}; + +const checkPermission = ( + permissions: MongoAbility, + subject: ProjectPermissionSub, + action: string, + subjectFields: any +): boolean => { + if ( + subject === ProjectPermissionSub.Secrets && + (action === ProjectPermissionSecretActions.ReadValue || + action === ProjectPermissionSecretActions.DescribeSecret) + ) { + return hasSecretReadValueOrDescribePermission(permissions, action, subjectFields); + } + + return permissions.can( + // @ts-expect-error we are not specifying which so can't resolve if valid + action, + abilitySubject(subject, subjectFields) + ); +}; + export const createFolderNode = ({ folder, permissions, environment, subject, secretName, - actionRuleMap + actionRuleMap, + metadata }: { folder: TSecretFolderWithPath; permissions: MongoAbility; @@ -66,6 +172,7 @@ export const createFolderNode = ({ subject: ProjectPermissionSub; secretName: string; actionRuleMap: TActionRuleMap; + metadata: Array<{ key: string; value: string }>; }) => { const actions = Object.fromEntries( Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => { @@ -73,74 +180,26 @@ export const createFolderNode = ({ // 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: ["*"] + secretTags: ["*"], + metadata: metadata.length ? metadata : ["*"] }; - 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) - ); - } + const hasPermission = checkPermission(permissions, subject, action, subjectFields); - if (hasPermission) { - // we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access - if ( - !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; - } + access = determineAccessLevel( + hasPermission, + subject, + action, + actionRuleMap, + environment, + folder.path, + secretName, + metadata + ); } catch (e) { console.error(e); access = PermissionAccess.None; @@ -150,18 +209,7 @@ export const createFolderNode = ({ }) ); - let height: number; - - switch (subject) { - case ProjectPermissionSub.DynamicSecrets: - height = 130; - break; - case ProjectPermissionSub.Secrets: - height = 85; - break; - default: - height = 64; - } + const height = SUBJECT_HEIGHT_MAP[subject] ?? SUBJECT_HEIGHT_MAP.default; return { type: PermissionNode.Folder, diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index c82e492e8..a1d4fb2a2 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -137,7 +137,7 @@ export const SecretPathInput = ({ maxHeight: "var(--radix-select-content-available-height)" }} > -
    +
    {suggestions.map((suggestion, i) => (