Merge pull request #3807 from Infisical/conditional-dynamic-secret-access-display

improvement(access-tree): dynamic secret conditional display
This commit is contained in:
Scott Wilson
2025-06-17 11:49:45 -07:00
committed by GitHub
8 changed files with 225 additions and 120 deletions
@@ -7,6 +7,7 @@ import React, {
useMemo, useMemo,
useState useState
} from "react"; } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { ViewMode } from "../types"; import { ViewMode } from "../types";
@@ -23,8 +24,11 @@ interface AccessTreeProviderProps {
children: ReactNode; children: ReactNode;
} }
export type AccessTreeForm = { metadata: { key: string; value: string }[] };
export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children }) => { export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children }) => {
const [secretName, setSecretName] = useState(""); const [secretName, setSecretName] = useState("");
const formMethods = useForm<AccessTreeForm>({ defaultValues: { metadata: [] } });
const [viewMode, setViewMode] = useState(ViewMode.Docked); const [viewMode, setViewMode] = useState(ViewMode.Docked);
const value = useMemo( const value = useMemo(
@@ -37,7 +41,11 @@ export const AccessTreeProvider: React.FC<AccessTreeProviderProps> = ({ children
[secretName, setSecretName, viewMode, setViewMode] [secretName, setSecretName, viewMode, setViewMode]
); );
return <AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>; return (
<FormProvider {...formMethods}>
<AccessTreeContext.Provider value={value}>{children}</AccessTreeContext.Provider>
</FormProvider>
);
}; };
export const useAccessTreeContext = (): AccessTreeContextProps => { export const useAccessTreeContext = (): AccessTreeContextProps => {
@@ -1,10 +1,12 @@
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
import { useFormContext } from "react-hook-form";
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons"; import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Panel } from "@xyflow/react"; import { Panel } from "@xyflow/react";
import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2"; import { Button, FormLabel, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSub } from "@app/context";
import { MetadataForm } from "@app/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm";
import { ViewMode } from "../types"; import { ViewMode } from "../types";
@@ -32,6 +34,7 @@ export const PermissionSimulation = ({
setSecretName setSecretName
}: TProps) => { }: TProps) => {
const [expand, setExpand] = useState(false); const [expand, setExpand] = useState(false);
const { control } = useFormContext();
const handlePermissionSimulation = () => { const handlePermissionSimulation = () => {
setExpand(true); setExpand(true);
@@ -139,6 +142,11 @@ export const PermissionSimulation = ({
/> />
</div> </div>
)} )}
{subject === ProjectPermissionSub.DynamicSecrets && (
<div>
<MetadataForm control={control} />
</div>
)}
</> </>
)} )}
</div> </div>
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { MongoAbility, MongoQuery } from "@casl/ability"; import { MongoAbility, MongoQuery } from "@casl/ability";
import { Edge, Node, useEdgesState, useNodesState } from "@xyflow/react"; 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 { useListProjectEnvironmentsFolders } from "@app/hooks/api/secretFolders/queries";
import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types"; import { TSecretFolderWithPath } from "@app/hooks/api/secretFolders/types";
import { useAccessTreeContext } from "../components"; import { AccessTreeForm, useAccessTreeContext } from "../components";
import { PermissionAccess } from "../types"; import { PermissionAccess } from "../types";
import { import {
createBaseEdge, createBaseEdge,
@@ -36,6 +37,8 @@ export const useAccessTree = (
) => { ) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext(); const { secretName, setSecretName, setViewMode, viewMode } = useAccessTreeContext();
const { control } = useFormContext<AccessTreeForm>();
const metadata = useWatch({ control, name: "metadata" });
const [nodes, setNodes] = useNodesState<Node>([]); const [nodes, setNodes] = useNodesState<Node>([]);
const [edges, setEdges] = useEdgesState<Edge>([]); const [edges, setEdges] = useEdgesState<Edge>([]);
const [subject, setSubject] = useState(ProjectPermissionSub.Secrets); const [subject, setSubject] = useState(ProjectPermissionSub.Secrets);
@@ -168,7 +171,8 @@ export const useAccessTree = (
environment, environment,
subject, subject,
secretName, secretName,
actionRuleMap actionRuleMap,
metadata
}) })
); );
@@ -266,7 +270,8 @@ export const useAccessTree = (
subject, subject,
secretName, secretName,
setNodes, setNodes,
setEdges setEdges,
metadata
]); ]);
return { return {
@@ -17,6 +17,27 @@ type Props = {
access: PermissionAccess; access: PermissionAccess;
} & Pick<ReturnType<typeof createFolderNode>["data"], "actionRuleMap" | "subject">; } & Pick<ReturnType<typeof createFolderNode>["data"], "actionRuleMap" | "subject">;
type ConditionDisplayProps = {
_key: string;
operator: string;
value: string | string[];
};
const ConditionDisplay = ({ _key: key, value, operator }: ConditionDisplayProps) => {
return (
<li>
<span className="font-medium capitalize text-mineshaft-100">{camelCaseToSpaces(key)}</span>{" "}
<span className="text-mineshaft-200">
{formatedConditionsOperatorNames[operator as PermissionConditionOperators]}
</span>{" "}
<span className="rounded bg-mineshaft-600 p-0.5 font-mono">
{typeof value === "string" ? value : value.join(", ")}
</span>
.
</li>
);
};
export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => { export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subject }: Props) => {
let component: ReactElement; let component: ReactElement;
@@ -56,43 +77,58 @@ export const FolderNodeTooltipContent = ({ action, access, actionRuleMap, subjec
{actionRuleMap.map((ruleMap, index) => { {actionRuleMap.map((ruleMap, index) => {
const rule = ruleMap[action]; const rule = ruleMap[action];
if ( if (!rule || !rule.conditions) return null;
!rule ||
!rule.conditions ||
(!rule.conditions.secretName && !rule.conditions.secretTags)
)
return null;
return ( if (
<li key={`${action}_${index + 1}`}> rule.conditions.secretName ||
<span className={`italic ${rule.inverted ? "text-red" : "text-green"} `}> rule.conditions.secretTags ||
{rule.inverted ? "Forbids" : "Allows"} rule.conditions.metadata
</span> ) {
<span> when:</span> return (
{Object.entries(rule.conditions).map(([key, condition]) => ( <li key={`${action}_${index + 1}`}>
<ul key={`${action}_${index + 1}_${key}`} className="list-[square] pl-4"> <span className="italic">{rule.inverted ? "Forbids" : "Allows"}</span>
{Object.entries(condition as object).map(([operator, value]) => ( <span> when:</span>
<li key={`${action}_${index + 1}_${key}_${operator}`}> {Object.entries(rule.conditions).map(([key, condition]) => {
<span className="font-medium capitalize text-mineshaft-100"> if (key.match(/secretPath|environment/)) {
{camelCaseToSpaces(key)} return null;
</span>{" "} }
<span className="text-mineshaft-200">
{ return (
formatedConditionsOperatorNames[ <ul key={`${action}_${index + 1}_${key}`} className="list-[square] pl-4">
operator as PermissionConditionOperators {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]) => (
<ConditionDisplay
_key={`${key} ${nestedKey}`}
operator={nestedOperator}
value={nestedValue}
key={`${action}_${index + 1}_${key}_${operator}_${nestedKey}_${nestedOperator}`}
/>
)
)
);
} }
</span>{" "}
<span className={rule.inverted ? "text-red" : "text-green"}> return (
{typeof value === "string" ? value : value.join(", ")} <ConditionDisplay
</span> _key={key}
. operator={operator}
</li> value={value}
))} key={`${action}_${index + 1}_${key}_${operator}`}
</ul> />
))} );
</li> })}
); </ul>
);
})}
</li>
);
}
return null;
})} })}
</ul> </ul>
</> </>
@@ -1,5 +1,5 @@
import { Dispatch, SetStateAction } from "react"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Handle, NodeProps, Position } from "@xyflow/react"; import { Handle, NodeProps, Position } from "@xyflow/react";
@@ -12,15 +12,15 @@ import { createRoleNode } from "../utils";
const getSubjectIcon = (subject: ProjectPermissionSub) => { const getSubjectIcon = (subject: ProjectPermissionSub) => {
switch (subject) { switch (subject) {
case ProjectPermissionSub.Secrets: case ProjectPermissionSub.Secrets:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />; return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-bunker-300" />;
case ProjectPermissionSub.SecretFolders: case ProjectPermissionSub.SecretFolders:
return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />; return <FontAwesomeIcon icon={faFolder} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.DynamicSecrets: case ProjectPermissionSub.DynamicSecrets:
return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-yellow-700" />; return <FontAwesomeIcon icon={faFingerprint} className="h-4 w-4 text-yellow-700" />;
case ProjectPermissionSub.SecretImports: case ProjectPermissionSub.SecretImports:
return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-yellow-700" />; return <FontAwesomeIcon icon={faFileImport} className="h-4 w-4 text-green-700" />;
default: default:
return <FontAwesomeIcon icon={faLock} className="h-4 w-4 text-yellow-700" />; return <FontAwesomeIcon icon={faKey} className="h-4 w-4 text-bunker-300" />;
} }
}; };
@@ -33,6 +33,12 @@ const ACTION_MAP: Record<string, string[] | undefined> = {
] ]
}; };
const SUBJECT_HEIGHT_MAP: Record<string, number> = {
[ProjectPermissionSub.DynamicSecrets]: 130,
[ProjectPermissionSub.Secrets]: 85,
default: 64
};
const evaluateCondition = ( const evaluateCondition = (
value: string, value: string,
operator: PermissionConditionOperators, operator: PermissionConditionOperators,
@@ -52,13 +58,113 @@ const evaluateCondition = (
} }
}; };
const doesConditionMatch = (
conditions: Record<string, any> | 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<ProjectPermissionSet, MongoQuery>,
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 = ({ export const createFolderNode = ({
folder, folder,
permissions, permissions,
environment, environment,
subject, subject,
secretName, secretName,
actionRuleMap actionRuleMap,
metadata
}: { }: {
folder: TSecretFolderWithPath; folder: TSecretFolderWithPath;
permissions: MongoAbility<ProjectPermissionSet, MongoQuery>; permissions: MongoAbility<ProjectPermissionSet, MongoQuery>;
@@ -66,6 +172,7 @@ export const createFolderNode = ({
subject: ProjectPermissionSub; subject: ProjectPermissionSub;
secretName: string; secretName: string;
actionRuleMap: TActionRuleMap; actionRuleMap: TActionRuleMap;
metadata: Array<{ key: string; value: string }>;
}) => { }) => {
const actions = Object.fromEntries( const actions = Object.fromEntries(
Object.values(ACTION_MAP[subject] ?? Object.values(ProjectPermissionActions)).map((action) => { 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 // wrapped in try because while editing certain conditions, if their values are empty it throws an error
try { try {
let hasPermission: boolean;
const subjectFields = { const subjectFields = {
secretPath: folder.path, secretPath: folder.path,
environment, environment,
secretName: secretName || "*", secretName: secretName || "*",
secretTags: ["*"] secretTags: ["*"],
metadata: metadata.length ? metadata : ["*"]
}; };
if ( const hasPermission = checkPermission(permissions, subject, action, subjectFields);
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) { access = determineAccessLevel(
// we want to show yellow/conditional access if user hasn't specified secret name to fully resolve access hasPermission,
if ( subject,
!secretName && action,
actionRuleMap.some((el) => { actionRuleMap,
// we only show conditional if secretName/secretTags are present - environment and path can be directly determined environment,
if (!el[action]?.conditions?.secretName && !el[action]?.conditions?.secretTags) folder.path,
return false; secretName,
metadata
// 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) { } catch (e) {
console.error(e); console.error(e);
access = PermissionAccess.None; access = PermissionAccess.None;
@@ -150,18 +209,7 @@ export const createFolderNode = ({
}) })
); );
let height: number; const height = SUBJECT_HEIGHT_MAP[subject] ?? SUBJECT_HEIGHT_MAP.default;
switch (subject) {
case ProjectPermissionSub.DynamicSecrets:
height = 130;
break;
case ProjectPermissionSub.Secrets:
height = 85;
break;
default:
height = 64;
}
return { return {
type: PermissionNode.Folder, type: PermissionNode.Folder,
@@ -137,7 +137,7 @@ export const SecretPathInput = ({
maxHeight: "var(--radix-select-content-available-height)" maxHeight: "var(--radix-select-content-available-height)"
}} }}
> >
<div className="max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white"> <div className="thin-scrollbar max-h-[25vh] w-full flex-col items-center justify-center overflow-y-scroll rounded-md text-white">
{suggestions.map((suggestion, i) => ( {suggestions.map((suggestion, i) => (
<div <div
tabIndex={0} tabIndex={0}
@@ -163,7 +163,7 @@ export type IdentityManagementSubjectFields = {
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
[PermissionConditionOperators.$EQ]: "equal to", [PermissionConditionOperators.$EQ]: "equal to",
[PermissionConditionOperators.$IN]: "contains", [PermissionConditionOperators.$IN]: "in",
[PermissionConditionOperators.$ALL]: "contains all", [PermissionConditionOperators.$ALL]: "contains all",
[PermissionConditionOperators.$NEQ]: "not equal to", [PermissionConditionOperators.$NEQ]: "not equal to",
[PermissionConditionOperators.$GLOB]: "matches glob pattern", [PermissionConditionOperators.$GLOB]: "matches glob pattern",