Add frontend migration support for existing project to be blind-indexed

This commit is contained in:
Tuan Dang
2023-04-18 12:43:06 +03:00
parent b62ea41e02
commit acb90ee0f7
18 changed files with 212 additions and 138 deletions

View File

@@ -15,7 +15,7 @@ import { UnauthorizedRequestError, ValidationError } from '../../utils/errors';
import { EventService } from '../../services';
import { eventPushSecrets } from '../../events';
import { EESecretService, EELogService } from '../../ee/services';
import { TelemetryService } from '../../services';
import { TelemetryService, SecretService } from '../../services';
import { getChannelFromUserAgent } from '../../utils/posthog';
import { PERMISSION_WRITE_SECRETS } from '../../variables';
import { userHasNoAbility, userHasWorkspaceAccess, userHasWriteOnlyAbility } from '../../ee/helpers/checkMembershipPermissions';
@@ -51,29 +51,47 @@ export const batchSecrets = async (req: Request, res: Response) => {
const updateSecrets: BatchSecret[] = [];
const deleteSecrets: Types.ObjectId[] = [];
const actions: IAction[] = [];
// get secret blind index salt
const salt = await SecretService.getSecretBlindIndexSalt({
workspaceId: new Types.ObjectId(workspaceId)
});
requests.forEach((request) => {
for await (const request of requests) {
let secretBlindIndex = '';
switch (request.method) {
case 'POST':
secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({
secretName: request.secret.secretName,
salt
});
createSecrets.push({
...request.secret,
version: 1,
user: request.secret.type === SECRET_PERSONAL ? req.user : undefined,
environment,
workspace: new Types.ObjectId(workspaceId)
workspace: new Types.ObjectId(workspaceId),
secretBlindIndex
});
break;
case 'PATCH':
secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({
secretName: request.secret.secretName,
salt
});
updateSecrets.push({
...request.secret,
_id: new Types.ObjectId(request.secret._id)
_id: new Types.ObjectId(request.secret._id),
secretBlindIndex
});
break;
case 'DELETE':
deleteSecrets.push(new Types.ObjectId(request.secret._id));
break;
}
});
}
// handle create secrets
let createdSecrets: ISecret[] = [];
@@ -134,7 +152,10 @@ export const batchSecrets = async (req: Request, res: Response) => {
const updateOperations = updateSecrets.map((u) => ({
updateOne: {
filter: { _id: new Types.ObjectId(u._id) },
filter: {
_id: new Types.ObjectId(u._id),
workspace: new Types.ObjectId(workspaceId)
},
update: {
$inc: {
version: 1
@@ -154,6 +175,7 @@ export const batchSecrets = async (req: Request, res: Response) => {
type: listedSecretsObj[u._id.toString()].type,
environment,
isDeleted: false,
secretBlindIndex: u.secretBlindIndex,
secretKeyCiphertext: u.secretKeyCiphertext,
secretKeyIV: u.secretKeyIV,
secretKeyTag: u.secretKeyTag,

View File

@@ -1,11 +1,7 @@
import { Request, Response } from 'express';
import { Types } from 'mongoose';
import { Secret, Workspace, SecretBlindIndexData } from '../../models';
import { Secret } from '../../models';
import { SecretService } from'../../services';
import { BadRequestError } from '../../utils/errors';
import { decryptSymmetric } from '../../utils/crypto';
import { getEncryptionKey } from '../../config';
import * as argon2 from 'argon2';
/**
* Return whether or not all secrets in workspace with id [workspaceId]
@@ -18,12 +14,13 @@ export const getWorkspaceBlindIndexStatus = async (req: Request, res: Response)
const { workspaceId } = req.params;
const secretsWithoutBlindIndex = await Secret.countDocuments({
workspace: new Types.ObjectId(workspaceId)
workspace: new Types.ObjectId(workspaceId),
secretBlindIndex: {
$exists: false
}
});
const isBlindIndexed = secretsWithoutBlindIndex === 0;
return res.status(200).send(isBlindIndexed);
return res.status(200).send(secretsWithoutBlindIndex === 0);
}
/**
@@ -47,7 +44,6 @@ export const getWorkspaceSecrets = async (req: Request, res: Response) => {
* @param res
*/
export const nameWorkspaceSecrets = async (req: Request, res: Response) => {
interface SecretToUpdate {
secretName: string;
_id: string;
@@ -89,6 +85,6 @@ export const nameWorkspaceSecrets = async (req: Request, res: Response) => {
await Secret.bulkWrite(operations);
return res.status(200).send({
operations
message: 'Successfully named workspace secrets'
});
}

View File

@@ -15,16 +15,10 @@ export const getSecretSnapshot = async (req: Request, res: Response) => {
secretSnapshot = await SecretSnapshot
.findById(secretSnapshotId)
.populate({
path: 'secretVersions',
populate: {
path: 'tags',
model: 'Tag',
}
});
.populate('secretVersions');
if (!secretSnapshot) throw new Error('Failed to find secret snapshot');
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);

View File

@@ -222,6 +222,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
type,
user,
environment,
secretBlindIndex,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
@@ -240,6 +241,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
type,
user,
environment,
secretBlindIndex: secretBlindIndex ?? undefined,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
@@ -265,6 +267,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
type,
user,
environment,
secretBlindIndex,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
@@ -282,6 +285,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
user,
environment,
isDeleted: false,
secretBlindIndex: secretBlindIndex ?? undefined,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,

View File

@@ -13,6 +13,7 @@ export interface ISecretVersion {
user?: Types.ObjectId; // new
environment: string; // new
isDeleted: boolean;
secretBlindIndex?: string;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
@@ -57,6 +58,10 @@ const secretVersionSchema = new Schema<ISecretVersion>(
default: false,
required: true
},
secretBlindIndex: {
type: String,
select: false
},
secretKeyCiphertext: {
type: String,
required: true

View File

@@ -19,13 +19,13 @@ router.get(
'/:workspaceId/secrets/blind-index-status',
param('workspaceId').exists().isString().trim(),
validateRequest,
// requireAuth({
// acceptedAuthModes: [AUTH_MODE_JWT]
// }),
// requireWorkspaceAuth({
// acceptedRoles: [ADMIN],
// locationWorkspaceId: 'params',
// }),
requireAuth({
acceptedAuthModes: [AUTH_MODE_JWT]
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN],
locationWorkspaceId: 'params',
}),
workspacesController.getWorkspaceBlindIndexStatus
);
@@ -52,7 +52,7 @@ router.post( // allow admins to name all workspace secrets (part of blind indice
.withMessage('secretsToUpdate must be an array')
.customSanitizer((value) => {
return value.map((secret: any) => ({
secretName: secret.name,
secretName: secret.secretName,
_id: secret._id
}));
}),

View File

@@ -24,6 +24,7 @@ export interface BatchSecretRequest {
export interface BatchSecret {
_id: string;
type: 'shared' | 'personal',
secretBlindIndex: string;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;

View File

@@ -10,6 +10,7 @@ interface EncryptedSecretProps {
id: string;
createdAt: string;
environment: string;
secretName: string;
secretCommentCiphertext: string;
secretCommentIV: string;
secretCommentTag: string;
@@ -94,6 +95,7 @@ const encryptSecrets = async ({
id: secret.id,
createdAt: '',
environment: env,
secretName: secret.key,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,

View File

@@ -7,7 +7,9 @@ export {
useGetUserWorkspaces,
useGetUserWsEnvironments,
useGetWorkspaceById,
useGetWorkspaceIndexStatus,
useGetWorkspaceSecrets,
useNameWorkspaceSecrets,
useRenameWorkspace,
useToggleAutoCapitalization,
useUpdateWsEnvironment
} from './queries';
useUpdateWsEnvironment} from './queries';

View File

@@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@app/config/request';
import {
EncryptedSecret
} from '../secrets/types';
import {
CreateEnvironmentDTO,
CreateWorkspaceDTO,
DeleteEnvironmentDTO,
DeleteWorkspaceDTO,
GetWsEnvironmentDTO,
NameWorkspaceSecretsDTO,
RenameWorkspaceDTO,
ToggleAutoCapitalizationDTO,
UpdateEnvironmentDTO,
@@ -17,6 +21,8 @@ import {
const workspaceKeys = {
getWorkspaceById: (workspaceId: string) => [{ workspaceId }, 'workspace'] as const,
getWorkspaceSecrets: (workspaceId: string) => [{ workspaceId }, 'workspace-secrets'] as const,
getWorkspaceIndexStatus: (workspaceId: string) => [{ workspaceId}, 'workspace-index-status'] as const,
getWorkspaceMemberships: (orgId: string) => [{ orgId }, 'workspace-memberships'],
getAllUserWorkspace: ['workspaces'] as const,
getUserWsEnvironments: (workspaceId: string) => ['workspace-env', { workspaceId }] as const
@@ -26,14 +32,47 @@ const fetchWorkspaceById = async (workspaceId: string) => {
const { data } = await apiRequest.get<{ workspace: Workspace }>(
`/api/v1/workspace/${workspaceId}`
);
return data.workspace;
};
const fetchWorkspaceIndexStatus = async (workspaceId: string) => {
const { data } = await apiRequest.get<boolean>(
`/api/v3/workspaces/${workspaceId}/secrets/blind-index-status`
);
return data;
}
const fetchWorkspaceSecrets = async (workspaceId: string) => {
const { data: { secrets } } = await apiRequest.get<{ secrets: EncryptedSecret[] }>(
`/api/v3/workspaces/${workspaceId}/secrets`
);
return secrets;
}
const fetchUserWorkspaces = async () => {
const { data } = await apiRequest.get<{ workspaces: Workspace[] }>('/api/v1/workspace');
return data.workspaces;
};
export const useGetWorkspaceIndexStatus = (workspaceId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceIndexStatus(workspaceId),
queryFn: () => fetchWorkspaceIndexStatus(workspaceId),
enabled: true
});
}
export const useGetWorkspaceSecrets = (workspaceId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSecrets(workspaceId),
queryFn: () => fetchWorkspaceSecrets(workspaceId),
enabled: true
})
}
export const useGetWorkspaceById = (workspaceId: string) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceById(workspaceId),
@@ -75,6 +114,20 @@ export const useGetUserWorkspaceMemberships = (orgId: string) =>
enabled: Boolean(orgId)
});
export const useNameWorkspaceSecrets = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, NameWorkspaceSecretsDTO>({
mutationFn: async ({ workspaceId, secretsToUpdate }) =>
apiRequest.post(`/api/v3/workspaces/${workspaceId}/secrets/names`, {
secretsToUpdate
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceIndexStatus(variables.workspaceId));
}
});
}
// mutation
export const useCreateWorkspace = () => {
const queryClient = useQueryClient();

View File

@@ -16,6 +16,14 @@ export type WorkspaceEnv = {
export type WorkspaceTag = { _id: string; name: string; slug: string };
export type NameWorkspaceSecretsDTO = {
workspaceId: string;
secretsToUpdate: {
secretName: string;
_id: string;
}[];
}
// mutation dto
export type CreateWorkspaceDTO = {
workspaceName: string;

View File

@@ -586,6 +586,7 @@ export default function Dashboard() {
method: 'POST',
secret: {
type: secret.type,
secretName: secret.secretName,
secretKeyCiphertext: secret.secretKeyCiphertext,
secretKeyIV: secret.secretKeyIV,
secretKeyTag: secret.secretKeyTag,
@@ -614,6 +615,7 @@ export default function Dashboard() {
secret: {
_id: secret.id,
type: secret.type,
secretName: secret.secretName,
secretKeyCiphertext: secret.secretKeyCiphertext,
secretKeyIV: secret.secretKeyIV,
secretKeyTag: secret.secretKeyTag,

View File

@@ -9,8 +9,8 @@ import NavHeader from '@app/components/navigation/NavHeader';
// TODO(akhilmhdh):Refactor this into a better utility module package
import {
decryptAssymmetric,
encryptSymmetric
} from '@app/components/utilities/cryptography/crypto';
decryptSymmetric,
encryptSymmetric} from '@app/components/utilities/cryptography/crypto';
import { Button, FormControl, Input } from '@app/components/v2';
import { plans } from '@app/const';
import { useSubscription, useWorkspace } from '@app/context';
@@ -25,11 +25,13 @@ import {
useDeleteWsTag,
useGetUserWsKey,
useGetUserWsServiceTokens,
useGetWorkspaceIndexStatus,
useGetWorkspaceSecrets,
useGetWsTags,
useNameWorkspaceSecrets,
useRenameWorkspace,
useToggleAutoCapitalization,
useUpdateWsEnvironment
} from '@app/hooks/api';
useUpdateWsEnvironment} from '@app/hooks/api';
import { AutoCapitalizationSection } from './components/AutoCapitalizationSection/AutoCapitalizationSection';
import { SecretTagsSection } from './components/SecretTagsSection';
@@ -39,9 +41,10 @@ import {
CreateUpdateEnvFormData,
CreateWsTag,
EnvironmentSection,
ProjectEncryptionModeSection,
ProjectIndexSecretsSection,
ProjectNameChangeSection,
ServiceTokenSection} from './components';
ServiceTokenSection
} from './components';
export const ProjectSettingsPage = () => {
const { t } = useTranslation();
@@ -55,6 +58,7 @@ export const ProjectSettingsPage = () => {
const [isDeleting, setIsDeleting] = useToggle();
const renameWorkspace = useRenameWorkspace();
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
const toggleAutoCapitalization = useToggleAutoCapitalization();
const deleteWorkspace = useDeleteWorkspace();
@@ -63,11 +67,16 @@ export const ProjectSettingsPage = () => {
const updateWsEnv = useUpdateWsEnvironment();
const deleteWsEnv = useDeleteWsEnvironment();
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(workspaceID);
// service token
const { data: serviceTokens, isLoading: isServiceTokenLoading } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?._id || ''
});
const { data: latestFileKey } = useGetUserWsKey(workspaceID);
const { data: encryptedSecrets } = useGetWorkspaceSecrets(workspaceID);
const createServiceToken = useCreateServiceToken();
const deleteServiceToken = useDeleteServiceToken();
@@ -207,14 +216,15 @@ export const ProjectSettingsPage = () => {
// type guard
if (!latestFileKey) return '';
try {
// crypo calculation to generate the key
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem('PRIVATE_KEY') as string
});
const randomBytes = crypto.randomBytes(16).toString('hex');
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: key,
key: randomBytes
@@ -303,6 +313,38 @@ export const ProjectSettingsPage = () => {
}
};
const onEnableBlindIndices = async () => {
if (!currentWorkspace?._id) return;
if (!encryptedSecrets) return;
if (!latestFileKey) return;
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem('PRIVATE_KEY') as string
});
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
const secretName = decryptSymmetric({
ciphertext: encryptedSecret.secretKeyCiphertext,
iv: encryptedSecret.secretKeyIV,
tag: encryptedSecret.secretKeyTag,
key
});
return ({
secretName,
_id: encryptedSecret._id
});
});
await nameWorkspaceSecrets.mutateAsync({
workspaceId: currentWorkspace._id,
secretsToUpdate
});
}
return (
<div className="dark container mx-auto flex flex-col px-8 text-mineshaft-50 dark:[color-scheme:dark]">
{/* TODO(akhilmhdh): Remove this right when layout is refactored */}
@@ -349,7 +391,11 @@ export const ProjectSettingsPage = () => {
workspaceAutoCapitalization={currentWorkspace?.autoCapitalization}
onAutoCapitalizationChange={onAutoCapitalizationToggle}
/>
<ProjectEncryptionModeSection />
{!isBlindIndexedLoading && !isBlindIndexed && (
<ProjectIndexSecretsSection
onEnableBlindIndices={onEnableBlindIndices}
/>
)}
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md border-l border-red bg-white/5 px-6 pl-6 pb-4 pt-4">
<p className="text-xl font-bold text-red">{t('settings-project:danger-zone')}</p>
<p className="text-md mt-2 text-gray-400">{t('settings-project:danger-zone-note')}</p>

View File

@@ -1,93 +0,0 @@
import { useEffect } from 'react';
import {
Controller,
useForm
} from 'react-hook-form';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import {
Button,
FormControl,
Select,
SelectItem
} from '@app/components/v2';
// TODO: modify in accordance with what was discussed with
// Maidul
// Do with select for now them replace.
const items = [
{ value: 'e2ee', label: 'E2EE' },
{ value: 'blind-indexed-e2ee', label: 'Blind Indexed E2EE' }
];
const formSchema = yup.object({
mode: yup.string().required().label('Project Mode')
});
type FormData = yup.InferType<typeof formSchema>;
export const ProjectEncryptionModeSection = () => {
const {
handleSubmit,
control,
reset,
formState: { isDirty, isSubmitting }
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
useEffect(() => {
reset({ mode: 'blind-indexed-e2ee' });
}, []);
const onFormSubmit = async ({ mode }: FormData) => {
console.log('onFormSubmit');
console.log('mode: ', mode);
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="rounded-md bg-white/5 p-6"
>
<p className="mb-4 text-xl font-semibold">Encryption Mode</p>
<div className="mb-6 max-w-lg">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => {
console.log('field: ', field);
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Select
{...field}
className="w-full"
>
{items.map(item => (
<SelectItem value={item.value} key={`enc-mode-${item.value}`}>
{item.label}
</SelectItem>
))}
</Select>
</FormControl>
);
}}
control={control}
name="mode"
/>
</div>
<Button
isLoading={isSubmitting}
color="mineshaft"
size="sm"
type="submit"
isDisabled={!isDirty || isSubmitting}
leftIcon={<FontAwesomeIcon icon={faCheck} />}
>
Save Changes
</Button>
</form>
);
}

View File

@@ -1 +0,0 @@
export { ProjectEncryptionModeSection } from './ProjectEncryptionModeSection';

View File

@@ -0,0 +1,32 @@
import { Button } from '@app/components/v2';
// TODO: add check so that this only shows up if user is
// an admin in the workspace
type Props = {
onEnableBlindIndices: () => Promise<void>;
}
export const ProjectIndexSecretsSection = ({
onEnableBlindIndices
}: Props) => {
return (
<div className="rounded-md bg-white/5 p-6">
<p className="mb-4 text-xl font-semibold">Blind Indices</p>
<p className="mb-4 text-sm text-gray-400">
Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing.
</p>
<p className="mb-4 text-sm text-gray-400">
Learn more about it here.
</p>
<Button
onClick={onEnableBlindIndices}
color="mineshaft"
size="sm"
type="submit"
>
Enable Blind Indexing
</Button>
</div>
);
}

View File

@@ -0,0 +1 @@
export { ProjectIndexSecretsSection } from './ProjectIndexSecretsSection';

View File

@@ -1,7 +1,7 @@
export { CopyProjectIDSection } from './CopyProjectIDSection';
export { EnvironmentSection } from './EnvironmentSection';
export type { CreateUpdateEnvFormData } from './EnvironmentSection/EnvironmentSection';
export { ProjectEncryptionModeSection } from './ProjectEncryptionModeSection';
export { ProjectIndexSecretsSection } from './ProjectIndexSecretsSection';
export { ProjectNameChangeSection } from './ProjectNameChangeSection';
export type { CreateWsTag } from './SecretTagsSection/SecretTagsSection';
export { ServiceTokenSection } from './ServiceTokenSection';