From 88c0a46de3456b7dec37bb4ef2fcbe92cc91de49 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 29 Mar 2023 18:07:15 +0700 Subject: [PATCH] Clean up and weave crypto into service account permissions --- .../v2/serviceAccountsController.ts | 129 +++++++++++------- backend/src/middleware/index.ts | 4 +- ...eServiceAccountWorkspacePermissionAuth.ts} | 14 +- backend/src/models/index.ts | 12 +- .../serviceAccountOrganizationPermission.ts | 13 +- ...s => serviceAccountWorkspacePermission.ts} | 8 +- backend/src/routes/v2/serviceAccounts.ts | 66 ++++----- .../utilities/cryptography/crypto.ts | 30 +++- .../src/hooks/api/serviceAccounts/index.tsx | 7 +- .../src/hooks/api/serviceAccounts/queries.tsx | 47 +++---- .../src/hooks/api/serviceAccounts/types.ts | 27 +--- frontend/src/layouts/AppLayout/AppLayout.tsx | 9 +- .../pages/api/workspace/getLatestFileKey.ts | 19 +-- .../service-accounts/[serviceAccountId].tsx | 8 +- .../CreateServiceAccountPage.tsx | 32 +++-- .../CopyServiceAccountIDSection.tsx | 49 +++++++ .../CopyServiceAccountIDSection/index.tsx | 1 + .../SAProjectLevelPermissionsTable.tsx | 114 ++++++++++++---- .../components/index.tsx | 1 + .../OrgServiceAccountsTable.tsx | 10 -- 20 files changed, 360 insertions(+), 240 deletions(-) rename backend/src/middleware/{requireServiceAccountWorkspacePermissionsAuth.ts => requireServiceAccountWorkspacePermissionAuth.ts} (71%) rename backend/src/models/{serviceAccountWorkspacePermissions.ts => serviceAccountWorkspacePermission.ts} (72%) create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/CopyServiceAccountIDSection.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx diff --git a/backend/src/controllers/v2/serviceAccountsController.ts b/backend/src/controllers/v2/serviceAccountsController.ts index 7d79fb6d7..07820f7fb 100644 --- a/backend/src/controllers/v2/serviceAccountsController.ts +++ b/backend/src/controllers/v2/serviceAccountsController.ts @@ -5,8 +5,8 @@ import bcrypt from 'bcrypt'; import { ServiceAccount, ServiceAccountKey, - ServiceAccountOrganizationPermissions, - ServiceAccountWorkspacePermissions + ServiceAccountOrganizationPermission, + ServiceAccountWorkspacePermission } from '../../models'; import { CreateServiceAccountDto @@ -71,8 +71,8 @@ export const createServiceAccount = async (req: Request, res: Response) => { delete serviceAccountObj.secretHash; - // provision default org-level permissions for service account - const permissions = await new ServiceAccountOrganizationPermissions({ + // provision default org-level permission for service account + await new ServiceAccountOrganizationPermission({ serviceAccount: serviceAccount._id }).save(); @@ -136,57 +136,57 @@ export const addServiceAccountKey = async (req: Request, res: Response) => { return serviceAccountKey; } -/** - * Return organization-level permissions for service account with id [serviceAccountId] - * @param req - * @param res - */ -export const getServiceAccountOrganizationPermissions = async (req: Request, res: Response) => { - const { serviceAccountId } = req.params; +// /** +// * Return organization-level permissions for service account with id [serviceAccountId] +// * @param req +// * @param res +// */ +// export const getServiceAccountOrganizationPermissions = async (req: Request, res: Response) => { +// const { serviceAccountId } = req.params; - const permissions = await ServiceAccountOrganizationPermissions.findOne({ - serviceAccount: new Types.ObjectId(serviceAccountId), - }); +// const permissions = await ServiceAccountOrganizationPermissions.findOne({ +// serviceAccount: new Types.ObjectId(serviceAccountId), +// }); - return res.status(200).send({ - permissions - }); -} +// return res.status(200).send({ +// permissions +// }); +// } /** - * Return workspace-level permissions for service account with id [serviceAccountId] + * Return workspace-level permission for service account with id [serviceAccountId] * @param req * @param res */ export const getServiceAccountWorkspacePermissions = async (req: Request, res: Response) => { - const permissions = await ServiceAccountWorkspacePermissions.find({ + const serviceAccountWorkspacePermissions = await ServiceAccountWorkspacePermission.find({ serviceAccount: req.serviceAccount._id }).populate('workspace'); return res.status(200).send({ - permissions + serviceAccountWorkspacePermissions }); } -/** - * Add organization permissions to service account with id [serviceAccountId] - * @param req - * @param res - */ -export const addServiceAccountOrganizationPermission = async (req: Request, res: Response) => { - const permissions = ServiceAccountOrganizationPermissions.findOne({ - serviceAccount: req.serviceAccount._id - }); +// /** +// * Add organization permissions to service account with id [serviceAccountId] +// * @param req +// * @param res +// */ +// export const addServiceAccountOrganizationPermission = async (req: Request, res: Response) => { +// const permissions = ServiceAccountOrganizationPermissions.findOne({ +// serviceAccount: req.serviceAccount._id +// }); - // TODO +// // TODO - return res.status(200).send({ - permissions - }); -} +// return res.status(200).send({ +// permissions +// }); +// } /** - * Add a workspace permissions to service account with id [serviceAccountId] + * Add a workspace permission to service account with id [serviceAccountId] * @param req * @param res */ @@ -198,7 +198,9 @@ export const addServiceAccountWorkspacePermission = async (req: Request, res: Re canRead = false, canWrite = false, canUpdate = false, - canDelete = false + canDelete = false, + encryptedKey, + nonce } = req.body; if (!req.membership.workspace.environments.some((e: { name: string; slug: string }) => e.slug === environment)) { @@ -207,7 +209,7 @@ export const addServiceAccountWorkspacePermission = async (req: Request, res: Re }); } - const existingPermission = await ServiceAccountWorkspacePermissions.findOne({ + const existingPermission = await ServiceAccountWorkspacePermission.findOne({ serviceAccount: new Types.ObjectId(serviceAccountId), workspaceId: new Types.ObjectId(workspaceId), environment @@ -215,7 +217,7 @@ export const addServiceAccountWorkspacePermission = async (req: Request, res: Re if (existingPermission) throw BadRequestError({ message: 'Failed to add workspace permission to service account due to already-existing ' }); - const permissions = await new ServiceAccountWorkspacePermissions({ + const serviceAccountWorkspacePermission = await new ServiceAccountWorkspacePermission({ serviceAccount: new Types.ObjectId(serviceAccountId), workspace: new Types.ObjectId(workspaceId), environment, @@ -225,23 +227,52 @@ export const addServiceAccountWorkspacePermission = async (req: Request, res: Re canDelete }).save(); + const existingServiceAccountKey = await ServiceAccountKey.findOne({ + serviceAccount: new Types.ObjectId(serviceAccountId), + workspace: new Types.ObjectId(workspaceId) + }); + + if (!existingServiceAccountKey) { + await new ServiceAccountKey({ + encryptedKey, + nonce, + sender: req.user._id, + serviceAccount: new Types.ObjectId(serviceAccountId), + workspace: new Types.ObjectId(workspaceId) + }).save(); + } + return res.status(200).send({ - permissions + serviceAccountWorkspacePermission }); } /** - * Delete workspace permissions from service account with id [serviceAccountId] + * Delete workspace permission from service account with id [serviceAccountId] * @param req * @param res */ export const deleteServiceAccountWorkspacePermission = async (req: Request, res: Response) => { - const { serviceAccountWorkspacePermissionsId } = req.params; - - const permissions = await ServiceAccountWorkspacePermissions.findByIdAndDelete(serviceAccountWorkspacePermissionsId); - + const { serviceAccountWorkspacePermissionId } = req.params; + const serviceAccountWorkspacePermission = await ServiceAccountWorkspacePermission.findByIdAndDelete(serviceAccountWorkspacePermissionId); + + if (serviceAccountWorkspacePermission) { + const { serviceAccount, workspace } = serviceAccountWorkspacePermission; + const count = await ServiceAccountWorkspacePermission.countDocuments({ + serviceAccount, + workspace + }); + + if (count === 0) { + await ServiceAccountKey.findOneAndDelete({ + serviceAccount, + workspace + }); + } + } + return res.status(200).send({ - permissions + serviceAccountWorkspacePermission }); } @@ -257,17 +288,15 @@ export const deleteServiceAccount = async (req: Request, res: Response) => { const serviceAccount = await ServiceAccount.findByIdAndDelete(serviceAccountId); if (serviceAccount) { - // case: service account with id [serviceAccountId] was deleted - await ServiceAccountKey.deleteMany({ serviceAccount: serviceAccount._id }); - await ServiceAccountOrganizationPermissions.deleteMany({ + await ServiceAccountOrganizationPermission.deleteMany({ serviceAccount: new Types.ObjectId(serviceAccountId) }); - await ServiceAccountWorkspacePermissions.deleteMany({ + await ServiceAccountWorkspacePermission.deleteMany({ serviceAccount: new Types.ObjectId(serviceAccountId) }); } diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index f1069ddd4..039b8612d 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -11,7 +11,7 @@ import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizati import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; import requireServiceAccountAuth from './requireServiceAccountAuth'; -import requireServiceAccountWorkspacePermissionsAuth from './requireServiceAccountWorkspacePermissionsAuth'; +import requireServiceAccountWorkspacePermissionAuth from './requireServiceAccountWorkspacePermissionAuth'; import requireSecretAuth from './requireSecretAuth'; import requireSecretsAuth from './requireSecretsAuth'; import validateRequest from './validateRequest'; @@ -30,7 +30,7 @@ export { requireServiceTokenAuth, requireServiceTokenDataAuth, requireServiceAccountAuth, - requireServiceAccountWorkspacePermissionsAuth, + requireServiceAccountWorkspacePermissionAuth, requireSecretAuth, requireSecretsAuth, validateRequest diff --git a/backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts b/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts similarity index 71% rename from backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts rename to backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts index cb6f2980c..d28c72352 100644 --- a/backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts +++ b/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from 'express'; -import { ServiceAccount, ServiceAccountWorkspacePermissions } from '../models'; +import { ServiceAccount, ServiceAccountWorkspacePermission } from '../models'; import { ServiceAccountNotFoundError } from '../utils/errors'; @@ -9,7 +9,7 @@ import { type req = 'params' | 'body' | 'query'; -const requireServiceAccountWorkspacePermissionsAuth = ({ +const requireServiceAccountWorkspacePermissionAuth = ({ acceptedRoles, acceptedStatuses, location = 'params' @@ -19,14 +19,14 @@ const requireServiceAccountWorkspacePermissionsAuth = ({ location?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { - const serviceAccountWorkspacePermissionsId = req[location].serviceAccountWorkspacePermissionsId; - const serviceAccountWorkspacePermissions = await ServiceAccountWorkspacePermissions.findById(serviceAccountWorkspacePermissionsId); + const serviceAccountWorkspacePermissionId = req[location].serviceAccountWorkspacePermissionId; + const serviceAccountWorkspacePermission = await ServiceAccountWorkspacePermission.findById(serviceAccountWorkspacePermissionId); - if (!serviceAccountWorkspacePermissions) { + if (!serviceAccountWorkspacePermission) { return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account workspace permission' })); } - const serviceAccount = await ServiceAccount.findById(serviceAccountWorkspacePermissions.serviceAccount); + const serviceAccount = await ServiceAccount.findById(serviceAccountWorkspacePermission.serviceAccount); if (!serviceAccount) { return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account' })); @@ -49,4 +49,4 @@ const requireServiceAccountWorkspacePermissionsAuth = ({ } } -export default requireServiceAccountWorkspacePermissionsAuth; \ No newline at end of file +export default requireServiceAccountWorkspacePermissionAuth; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 0474452c7..886f7fec6 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -12,8 +12,8 @@ import Secret, { ISecret } from './secret'; import ServiceToken, { IServiceToken } from './serviceToken'; import ServiceAccount, { IServiceAccount } from './serviceAccount'; // new import ServiceAccountKey, { IServiceAccountKey } from './serviceAccountKey'; // new -import ServiceAccountOrganizationPermissions, { IServiceAccountOrganizationPermissions } from './serviceAccountOrganizationPermission'; // new -import ServiceAccountWorkspacePermissions, { IServiceAccountWorkspacePermissions } from './serviceAccountWorkspacePermissions'; // new +import ServiceAccountOrganizationPermission, { IServiceAccountOrganizationPermission } from './serviceAccountOrganizationPermission'; // new +import ServiceAccountWorkspacePermission, { IServiceAccountWorkspacePermission } from './serviceAccountWorkspacePermission'; // new import TokenData, { ITokenData } from './tokenData'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; @@ -51,10 +51,10 @@ export { IServiceAccount, ServiceAccountKey, IServiceAccountKey, - ServiceAccountOrganizationPermissions, - IServiceAccountOrganizationPermissions, - ServiceAccountWorkspacePermissions, - IServiceAccountWorkspacePermissions, + ServiceAccountOrganizationPermission, + IServiceAccountOrganizationPermission, + ServiceAccountWorkspacePermission, + IServiceAccountWorkspacePermission, TokenData, ITokenData, User, diff --git a/backend/src/models/serviceAccountOrganizationPermission.ts b/backend/src/models/serviceAccountOrganizationPermission.ts index e616bfabc..6454bc6a0 100644 --- a/backend/src/models/serviceAccountOrganizationPermission.ts +++ b/backend/src/models/serviceAccountOrganizationPermission.ts @@ -1,21 +1,16 @@ import { Schema, model, Types, Document } from 'mongoose'; -export interface IServiceAccountOrganizationPermissions extends Document { +export interface IServiceAccountOrganizationPermission extends Document { _id: Types.ObjectId; serviceAccount: Types.ObjectId; - canFoo: boolean; } -const serviceAccountOrganizationPermissionsSchema = new Schema( +const serviceAccountOrganizationPermissionSchema = new Schema( { serviceAccount: { type: Schema.Types.ObjectId, ref: 'ServiceAccount', required: true - }, - canFoo: { - type: Boolean, - default: false } }, { @@ -23,6 +18,6 @@ const serviceAccountOrganizationPermissionsSchema = new Schema('ServiceAccountOrganizationPermissions', serviceAccountOrganizationPermissionsSchema); +const ServiceAccountOrganizationPermission = model('ServiceAccountOrganizationPermission', serviceAccountOrganizationPermissionSchema); -export default ServiceAccountOrganizationPermissions; \ No newline at end of file +export default ServiceAccountOrganizationPermission; \ No newline at end of file diff --git a/backend/src/models/serviceAccountWorkspacePermissions.ts b/backend/src/models/serviceAccountWorkspacePermission.ts similarity index 72% rename from backend/src/models/serviceAccountWorkspacePermissions.ts rename to backend/src/models/serviceAccountWorkspacePermission.ts index ea81867c5..6e9a4d86f 100644 --- a/backend/src/models/serviceAccountWorkspacePermissions.ts +++ b/backend/src/models/serviceAccountWorkspacePermission.ts @@ -1,6 +1,6 @@ import { Schema, model, Types, Document } from 'mongoose'; -export interface IServiceAccountWorkspacePermissions extends Document { +export interface IServiceAccountWorkspacePermission extends Document { _id: Types.ObjectId; serviceAccount: Types.ObjectId; workspace: Types.ObjectId; @@ -11,7 +11,7 @@ export interface IServiceAccountWorkspacePermissions extends Document { canDelete: boolean; } -const serviceAccountWorkspacePermissions = new Schema( +const serviceAccountWorkspacePermissionSchema = new Schema( { serviceAccount: { type: Schema.Types.ObjectId, @@ -49,6 +49,6 @@ const serviceAccountWorkspacePermissions = new Schema('ServiceAccountWorkspacePermissions', serviceAccountWorkspacePermissions); +const ServiceAccountWorkspacePermission = model('ServiceAccountWorkspacePermission', serviceAccountWorkspacePermissionSchema); -export default ServiceAccountWorkspacePermissions; \ No newline at end of file +export default ServiceAccountWorkspacePermission; \ No newline at end of file diff --git a/backend/src/routes/v2/serviceAccounts.ts b/backend/src/routes/v2/serviceAccounts.ts index 9d49985fa..5d177c7b1 100644 --- a/backend/src/routes/v2/serviceAccounts.ts +++ b/backend/src/routes/v2/serviceAccounts.ts @@ -5,7 +5,7 @@ import { requireOrganizationAuth, requireWorkspaceAuth, requireServiceAccountAuth, - requireServiceAccountWorkspacePermissionsAuth, + requireServiceAccountWorkspacePermissionAuth, validateRequest } from '../../middleware'; import { param, query, body } from 'express-validator'; @@ -76,21 +76,21 @@ router.delete( serviceAccountsController.deleteServiceAccount ); -router.get( - '/:serviceAccountId/permissions/organization', - param('serviceAccountId').exists().isString().trim(), - query('offset').exists(), - query('limit').exists(), - validateRequest, - requireAuth({ - acceptedAuthModes: ['jwt'] - }), - requireServiceAccountAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED] - }), - serviceAccountsController.getServiceAccountOrganizationPermissions -); +// router.get( +// '/:serviceAccountId/permissions/organization', +// param('serviceAccountId').exists().isString().trim(), +// query('offset').exists(), +// query('limit').exists(), +// validateRequest, +// requireAuth({ +// acceptedAuthModes: ['jwt'] +// }), +// requireServiceAccountAuth({ +// acceptedRoles: [OWNER, ADMIN], +// acceptedStatuses: [ACCEPTED] +// }), +// serviceAccountsController.getServiceAccountOrganizationPermission +// ); router.get( '/:serviceAccountId/permissions/workspace', @@ -106,19 +106,19 @@ router.get( serviceAccountsController.getServiceAccountWorkspacePermissions ); -router.post( - '/:serviceAccountId/permissions/organization', - param('serviceAccountId').exists().isString().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: ['jwt'] - }), - requireServiceAccountAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED] - }), - serviceAccountsController.addServiceAccountOrganizationPermission -); +// router.post( +// '/:serviceAccountId/permissions/organization', +// param('serviceAccountId').exists().isString().trim(), +// validateRequest, +// requireAuth({ +// acceptedAuthModes: ['jwt'] +// }), +// requireServiceAccountAuth({ +// acceptedRoles: [OWNER, ADMIN], +// acceptedStatuses: [ACCEPTED] +// }), +// serviceAccountsController.addServiceAccountOrganizationPermission +// ); router.post( '/:serviceAccountId/permissions/workspace', @@ -129,6 +129,8 @@ router.post( body('canWrite').isBoolean().optional(), body('canUpdate').isBoolean().optional(), body('canDelete').isBoolean().optional(), + body('encryptedKey').exists().isString().notEmpty(), + body('nonce').exists().isString().notEmpty(), validateRequest, requireAuth({ acceptedAuthModes: ['jwt'] @@ -145,9 +147,9 @@ router.post( ); router.delete( - '/:serviceAccountId/permissions/workspace/:serviceAccountWorkspacePermissionsId', + '/:serviceAccountId/permissions/workspace/:serviceAccountWorkspacePermissionId', param('serviceAccountId').exists().isString().trim(), - param('serviceAccountWorkspacePermissionsId').exists().isString().trim(), + param('serviceAccountWorkspacePermissionId').exists().isString().trim(), validateRequest, requireAuth({ acceptedAuthModes: ['jwt'] @@ -156,7 +158,7 @@ router.delete( acceptedRoles: [OWNER, ADMIN], acceptedStatuses: [ACCEPTED] }), - requireServiceAccountWorkspacePermissionsAuth({ + requireServiceAccountWorkspacePermissionAuth({ acceptedRoles: [OWNER, ADMIN], acceptedStatuses: [ACCEPTED] }), diff --git a/frontend/src/components/utilities/cryptography/crypto.ts b/frontend/src/components/utilities/cryptography/crypto.ts index d1a6fb5da..43ef1f401 100644 --- a/frontend/src/components/utilities/cryptography/crypto.ts +++ b/frontend/src/components/utilities/cryptography/crypto.ts @@ -26,6 +26,31 @@ type EncryptAsymmetricProps = { privateKey: string; }; +/** + * Verify that private key [privateKey] is the one that corresponds to + * the public key [publicKey] + * @param {Object} + * @param {String} - base64-encoded Nacl private key + * @param {String} - base64-encoded Nacl public key + */ +const verifyPrivateKey = ({ + privateKey, + publicKey +}: { + privateKey: string; + publicKey: string; +}) => { + const derivedPublicKey = nacl.util.encodeBase64( + nacl.box.keyPair.fromSecretKey( + nacl.util.decodeBase64(privateKey) + ).publicKey + ); + + if (derivedPublicKey !== publicKey) { + throw new Error('Failed to verify private key'); + } +} + /** * Derive a key from password [password] and salt [salt] using Argon2id * @param {Object} obj @@ -204,5 +229,6 @@ export { decryptSymmetric, deriveArgonKey, encryptAssymmetric, - encryptSymmetric, - generateKeyPair}; + encryptSymmetric, + generateKeyPair, + verifyPrivateKey}; diff --git a/frontend/src/hooks/api/serviceAccounts/index.tsx b/frontend/src/hooks/api/serviceAccounts/index.tsx index f9176b69a..a063d40dd 100644 --- a/frontend/src/hooks/api/serviceAccounts/index.tsx +++ b/frontend/src/hooks/api/serviceAccounts/index.tsx @@ -1,9 +1,10 @@ export { useCreateServiceAccount, - useCreateServiceAccountProjectLevelPermissions, + useCreateServiceAccountProjectLevelPermission, useDeleteServiceAccount, - useDeleteServiceAccountProjectLevelPermissions, + useDeleteServiceAccountProjectLevelPermission, useGetServiceAccountById, useGetServiceAccountProjectLevelPermissions, useGetServiceAccounts, - useRenameServiceAccount} from './queries'; \ No newline at end of file + useRenameServiceAccount +} from './queries'; \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceAccounts/queries.tsx b/frontend/src/hooks/api/serviceAccounts/queries.tsx index 62dab2895..ec7888547 100644 --- a/frontend/src/hooks/api/serviceAccounts/queries.tsx +++ b/frontend/src/hooks/api/serviceAccounts/queries.tsx @@ -5,14 +5,12 @@ import { apiRequest } from '@app/config/request'; import { CreateServiceAccountDTO, CreateServiceAccountRes, - CreateServiceAccountWorkspacePermissionsDTO, - DeleteServiceAccountRes, - DeleteServiceAccountWorkspacePermissionsDTO, - DeleteServiceAccountWorkspacePermissionsRes, + CreateServiceAccountWorkspacePermissionDTO, + DeleteServiceAccountWorkspacePermissionDTO, RenameServiceAccountDTO, - RenameServiceAccountRes, ServiceAccount, - ServiceAccountWorkspacePermissions} from './types'; + ServiceAccountWorkspacePermission +} from './types'; const serviceAccountKeys = { getServiceAccountById: (serviceAccountId: string) => [{ serviceAccountId }, 'service-account'] as const, @@ -68,7 +66,7 @@ export const useCreateServiceAccount = () => { export const useRenameServiceAccount = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async ({ serviceAccountId, name }) => { const { data: { serviceAccount } } = await apiRequest.patch(`/api/v2/service-accounts/${serviceAccountId}/name`, { name }); return serviceAccount; @@ -83,7 +81,7 @@ export const useRenameServiceAccount = () => { export const useDeleteServiceAccount = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async (serviceAccountId) => { const { data: { serviceAccount } } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}`); return serviceAccount; @@ -95,14 +93,11 @@ export const useDeleteServiceAccount = () => { } const fetchServiceAccountProjectLevelPermissions = async (serviceAccountId: string) => { - const { data: { permissions } } = await apiRequest.get<{ permissions: ServiceAccountWorkspacePermissions[] }>( + const { data: { serviceAccountWorkspacePermissions } } = await apiRequest.get<{ serviceAccountWorkspacePermissions: ServiceAccountWorkspacePermission[] }>( `/api/v2/service-accounts/${serviceAccountId}/permissions/workspace` ); - console.log('fetchServiceAccountProjectLevelPermissions'); - console.log('prrr: ', permissions); - - return permissions; + return serviceAccountWorkspacePermissions; } export const useGetServiceAccountProjectLevelPermissions = (serviceAccountId: string) => { @@ -113,13 +108,13 @@ export const useGetServiceAccountProjectLevelPermissions = (serviceAccountId: st }); } -export const useCreateServiceAccountProjectLevelPermissions = () => { +export const useCreateServiceAccountProjectLevelPermission = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async (body) => { - const { data: { permissions } } = await apiRequest.post(`/api/v2/service-accounts/${body.serviceAccountId}/permissions/workspace`, body); - return permissions; + const { data: { serviceAccountWorkspacePermission } } = await apiRequest.post(`/api/v2/service-accounts/${body.serviceAccountId}/permissions/workspace`, body); + return serviceAccountWorkspacePermission; }, onSuccess: ({ serviceAccount }) => { queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount)); @@ -127,20 +122,16 @@ export const useCreateServiceAccountProjectLevelPermissions = () => { }); } -export const useDeleteServiceAccountProjectLevelPermissions = () => { +export const useDeleteServiceAccountProjectLevelPermission = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ serviceAccountId, serviceAccountWorkspacePermissionsId }) => { - const { data: { permissions } } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}/permissions/workspace/${serviceAccountWorkspacePermissionsId}`); - console.log('useDeleteServiceAccountProjectLevelPermissions'); - console.log('permissions: ', permissions); - return permissions; + return useMutation({ + mutationFn: async ({ serviceAccountId, serviceAccountWorkspacePermissionId }) => { + const { data: { serviceAccountWorkspacePermission} } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}/permissions/workspace/${serviceAccountWorkspacePermissionId}`); + return serviceAccountWorkspacePermission; }, - onSuccess: ({ serviceAccount }) => { - console.log('onSuccess3: ', serviceAccount); - queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount)); - // queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(organization)); + onSuccess: (serviceAccountWorkspacePermission) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccountWorkspacePermission.serviceAccount)); } }); } \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceAccounts/types.ts b/frontend/src/hooks/api/serviceAccounts/types.ts index 2b5fc5b81..a4da950be 100644 --- a/frontend/src/hooks/api/serviceAccounts/types.ts +++ b/frontend/src/hooks/api/serviceAccounts/types.ts @@ -19,20 +19,13 @@ export type CreateServiceAccountRes = { serviceAccountAccessKey: string; } -export type DeleteServiceAccountRes = { - serviceAccount: ServiceAccount; -} - export type RenameServiceAccountDTO = { serviceAccountId: string; name: string; } -export type RenameServiceAccountRes = { - serviceAccount: ServiceAccount; -} - -export type ServiceAccountWorkspacePermissions = { +export type ServiceAccountWorkspacePermission = { + _id: string; serviceAccount: string; workspace: string; environment: string; @@ -42,7 +35,7 @@ export type ServiceAccountWorkspacePermissions = { canDelete: boolean; } -export type CreateServiceAccountWorkspacePermissionsDTO = { +export type CreateServiceAccountWorkspacePermissionDTO = { serviceAccountId: string; workspaceId: string; environment: string; @@ -50,17 +43,11 @@ export type CreateServiceAccountWorkspacePermissionsDTO = { canWrite: boolean; canUpdate: boolean; canDelete: boolean; + encryptedKey: string; + nonce: string; } -export type CreateServiceAccountWorkspacePermissionsRes = { - permissions: ServiceAccountWorkspacePermissions -} - -export type DeleteServiceAccountWorkspacePermissionsDTO = { +export type DeleteServiceAccountWorkspacePermissionDTO = { serviceAccountId: string; - serviceAccountWorkspacePermissionsId: string; -} - -export type DeleteServiceAccountWorkspacePermissionsRes = { - permissions: ServiceAccountWorkspacePermissions + serviceAccountWorkspacePermissionId: string; } \ No newline at end of file diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index b539bb845..93ed5b860 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -136,8 +136,13 @@ export const AppLayout = ({ children }: LayoutProps) => { } else if (pathSegments.length >= 3 && pathSegments[0] === 'settings') { [, , intendedWorkspaceId] = pathSegments; } else { - const lastPathSegment = router.asPath.split('/').pop().split('?'); - [intendedWorkspaceId] = lastPathSegment; + const lastPathSegments = router.asPath.split('/').pop(); + if (lastPathSegments !== undefined) { + [intendedWorkspaceId] = lastPathSegments.split('?'); + } + + // const lastPathSegment = router.asPath.split('/').pop().split('?'); + // [intendedWorkspaceId] = lastPathSegment; } if (!intendedWorkspaceId) return; diff --git a/frontend/src/pages/api/workspace/getLatestFileKey.ts b/frontend/src/pages/api/workspace/getLatestFileKey.ts index 576fcf943..f649987db 100644 --- a/frontend/src/pages/api/workspace/getLatestFileKey.ts +++ b/frontend/src/pages/api/workspace/getLatestFileKey.ts @@ -1,22 +1,13 @@ -import SecurityClient from '@app/components/utilities/SecurityClient'; +import { apiRequest } from '@app/config/request'; /** * Get the latest key pairs from a certain workspace * @param {string} workspaceId * @returns */ -const getLatestFileKey = ({ workspaceId }: { workspaceId: string }) => - SecurityClient.fetchCall(`/api/v1/key/${workspaceId}/latest`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - }).then(async (res) => { - if (res?.status === 200) { - return res.json(); - } - console.log('Failed to get the latest key pairs for a certain project'); - return undefined; - }); +const getLatestFileKey = async ({ workspaceId }: { workspaceId: string }) => { + const { data } = await apiRequest.get(`/api/v1/key/${workspaceId}/latest`); + return data; +} export default getLatestFileKey; diff --git a/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx b/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx index 332fbbf27..dff9486a8 100644 --- a/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx +++ b/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx @@ -1,23 +1,17 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import Head from 'next/head'; -import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; import { CreateServiceAccountPage } from '@app/views/Settings/CreateServiceAccountPage'; export default function ServiceAccountPage() { - const router = useRouter(); - // const { orgId, serviceAccountId } = router.query; - const { t } = useTranslation(); - return ( <> Edit Service Account -
); @@ -25,7 +19,7 @@ export default function ServiceAccountPage() { ServiceAccountPage.requireAuth = true; -export const getServerSidePros = getTranslatedServerSideProps([ +export const getServerSideProps = getTranslatedServerSideProps([ 'settings', 'settings-org', 'section-incident', diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx index d1c91ff35..2b331b244 100644 --- a/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx +++ b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx @@ -3,11 +3,13 @@ import { useRouter } from 'next/router'; import NavHeader from '@app/components/navigation/NavHeader'; import { SAProjectLevelPermissionsTable } from './components/SAProjectLevelPermissionsTable'; -import { ServiceAccountNameChangeSection } from './components'; +import { + CopyServiceAccountIDSection, + ServiceAccountNameChangeSection} from './components'; export const CreateServiceAccountPage = () => { const router = useRouter(); - const { serviceAccountId }: { serviceAccountId: string } = router.query; + const {serviceAccountId} = router.query; return (
@@ -21,23 +23,23 @@ export const CreateServiceAccountPage = () => { A service account represents a machine identity such as a VM or application client.

-
- {typeof serviceAccountId === 'string' && ( + {typeof serviceAccountId === 'string' && ( +
- )} - {/*
-

Organization-Level Permissions

- -
*/} -
-

Project-Level Permissions

- +
+ +
+
+ +
-
+ )}
); } \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/CopyServiceAccountIDSection.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/CopyServiceAccountIDSection.tsx new file mode 100644 index 000000000..768145b09 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/CopyServiceAccountIDSection.tsx @@ -0,0 +1,49 @@ +import { useEffect } from 'react'; +import { faCheck, faCopy } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import { IconButton } from '@app/components/v2'; +import { useToggle } from '@app/hooks'; + +type Props = { + serviceAccountId: string; +} + +export const CopyServiceAccountIDSection = ({ serviceAccountId }: Props): JSX.Element => { + const [isServiceAccountIdCopied, setIsServiceAccountIdCopied] = useToggle(false); + + useEffect(() => { + let timer: NodeJS.Timeout; + + if (isServiceAccountIdCopied) { + timer = setTimeout(() => setIsServiceAccountIdCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isServiceAccountIdCopied]); + + const copyServiceAccountIdToClipboard = () => { + navigator.clipboard.writeText(serviceAccountId); + setIsServiceAccountIdCopied.on(); + }; + + return ( +
+

Service Account ID

+
+

{serviceAccountId}

+ copyServiceAccountIdToClipboard()} + > + + + Copy + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx new file mode 100644 index 000000000..01cae04d6 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx @@ -0,0 +1 @@ +export { CopyServiceAccountIDSection } from './CopyServiceAccountIDSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx index e64f54b89..4d01bcba2 100644 --- a/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx @@ -9,6 +9,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; +import { + decryptAssymmetric, + encryptAssymmetric, + verifyPrivateKey} from '@app/components/utilities/cryptography/crypto'; import { Button, Checkbox, @@ -32,12 +36,15 @@ import { Tr} from '@app/components/v2'; import { usePopUp } from '@app/hooks'; import { - useCreateServiceAccountProjectLevelPermissions, - useDeleteServiceAccountProjectLevelPermissions, + useCreateServiceAccountProjectLevelPermission, + useDeleteServiceAccountProjectLevelPermission, + useGetServiceAccountById, useGetServiceAccountProjectLevelPermissions, useGetUserWorkspaces} from '@app/hooks/api'; +import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey'; const createProjectLevelPermissionSchema = yup.object({ + privateKey: yup.string().required().label('Private Key'), workspace: yup.string().required().label('Workspace'), environment: yup.string().required().label('Environment'), permissions: yup.object().shape({ @@ -57,18 +64,19 @@ type Props = { export const SAProjectLevelPermissionsTable = ({ serviceAccountId }: Props) => { + const { data: serviceAccount } = useGetServiceAccountById(serviceAccountId); const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces(); const [searchPermissions, setSearchPermissions] = useState(''); const [defaultValues, setDefaultValues] = useState(undefined); - const { data: permissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId); + const { data: serviceAccountWorkspacePermissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId); - const createServiceAccountProjectLevelPermissions = useCreateServiceAccountProjectLevelPermissions(); - const deleteServiceAccountProjectLevelPermissions = useDeleteServiceAccountProjectLevelPermissions(); + const createServiceAccountProjectLevelPermission = useCreateServiceAccountProjectLevelPermission(); + const deleteServiceAccountProjectLevelPermission = useDeleteServiceAccountProjectLevelPermission(); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - 'addProjectLevelPermissions', - 'removeProjectLevelPermissions', + 'addProjectLevelPermission', + 'removeProjectLevelPermission', ] as const); const { @@ -78,35 +86,68 @@ export const SAProjectLevelPermissionsTable = ({ formState: { isSubmitting } } = useForm({ resolver: yupResolver(createProjectLevelPermissionSchema), defaultValues }) - const onAddProjectLevelPermissions = async ({ + const onAddProjectLevelPermission = async ({ + privateKey, workspace, environment, permissions: { canRead, canWrite, canUpdate, canDelete } }: CreateProjectLevelPermissionForm) => { - await createServiceAccountProjectLevelPermissions.mutateAsync({ + + // TODO: clean up / modularize this function + + if (!serviceAccount) return; + + const { latestKey } = await getLatestFileKey({ + workspaceId: workspace + }); + + verifyPrivateKey({ + privateKey, + publicKey: serviceAccount.publicKey + }); + + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; + + const key = decryptAssymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: key, + publicKey: serviceAccount.publicKey, + privateKey: PRIVATE_KEY + }); + + await createServiceAccountProjectLevelPermission.mutateAsync({ serviceAccountId, workspaceId: workspace, environment, canRead, canWrite, canUpdate, - canDelete + canDelete, + encryptedKey: ciphertext, + nonce }); - handlePopUpClose('addProjectLevelPermissions'); + handlePopUpClose('addProjectLevelPermission'); } - const onRemoveProjectLevelPermissions = async () => { - const serviceAccountWorkspacePermissionsId = (popUp?.removeProjectLevelPermissions?.data as { _id: string })?._id; - await deleteServiceAccountProjectLevelPermissions.mutateAsync({ + const onRemoveProjectLevelPermission = async () => { + const serviceAccountWorkspacePermissionId = (popUp?.removeProjectLevelPermission?.data as { _id: string })?._id; + await deleteServiceAccountProjectLevelPermission.mutateAsync({ serviceAccountId, - serviceAccountWorkspacePermissionsId + serviceAccountWorkspacePermissionId }); - handlePopUpClose('removeProjectLevelPermissions'); + handlePopUpClose('removeProjectLevelPermission'); } useEffect(() => { if (userWorkspaces) { setDefaultValues({ + privateKey: '', workspace: String(userWorkspaces?.[0]?._id), environment: String(userWorkspaces?.[0]?.environments?.[0]?.slug), permissions: { @@ -118,10 +159,10 @@ export const SAProjectLevelPermissionsTable = ({ }); } }, [userWorkspaces]); - return ( -
+
+

Project-Level Permissions

} onClick={() => { - handlePopUpOpen('addProjectLevelPermissions') + handlePopUpOpen('addProjectLevelPermission') reset(); }} > @@ -156,8 +197,8 @@ export const SAProjectLevelPermissionsTable = ({ {isPermissionsLoading && } - {!isPermissionsLoading && permissions && ( - permissions.map(({ + {!isPermissionsLoading && serviceAccountWorkspacePermissions && ( + serviceAccountWorkspacePermissions.map(({ _id, workspace, environment, @@ -196,13 +237,14 @@ export const SAProjectLevelPermissionsTable = ({ handlePopUpOpen('removeProjectLevelPermissions', { _id })} + onClick={() => handlePopUpOpen('removeProjectLevelPermission', { _id })} > @@ -211,7 +253,7 @@ export const SAProjectLevelPermissionsTable = ({ ); }) )} - {!isPermissionsLoading && permissions?.length === 0 && ( + {!isPermissionsLoading && serviceAccountWorkspacePermissions?.length === 0 && ( @@ -222,18 +264,32 @@ export const SAProjectLevelPermissionsTable = ({ { - handlePopUpToggle('addProjectLevelPermissions', isOpen); + handlePopUpToggle('addProjectLevelPermission', isOpen); }} > -
+ {!isUserWorkspacesLoading && userWorkspaces && ( <> + ( + + + + )} + /> handlePopUpToggle('removeProjectLevelPermissions', isOpen)} - onDeleteApproved={onRemoveProjectLevelPermissions} + onChange={(isOpen) => handlePopUpToggle('removeProjectLevelPermission', isOpen)} + onDeleteApproved={onRemoveProjectLevelPermission} />
); diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx index 151430ddf..99bf3653e 100644 --- a/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx @@ -1,2 +1,3 @@ +export { CopyServiceAccountIDSection } from './CopyServiceAccountIDSection'; export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable'; export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx index 3bab7f4bc..7776c7d67 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx @@ -62,8 +62,6 @@ export const OrgServiceAccountsTable = () => { const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); - console.log('currentWorkspace: ', currentWorkspace); - const orgId = currentOrg?._id || ''; const [step, setStep] = useState(0); const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false); @@ -114,8 +112,6 @@ export const OrgServiceAccountsTable = () => { expiresIn }); - console.log('serviceAccountDetails: ', serviceAccountDetails); - setAccessKey(serviceAccountDetails.serviceAccountAccessKey); setStep(1); @@ -123,11 +119,7 @@ export const OrgServiceAccountsTable = () => { } const onRemoveServiceAccount = async () => { - console.log('onRemoveServiceAccount'); - const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id; - console.log('serviceAccountId: ', serviceAccountId); - await removeServiceAccount.mutateAsync(serviceAccountId); handlePopUpClose('removeServiceAccount'); } @@ -247,8 +239,6 @@ export const OrgServiceAccountsTable = () => { } } - console.log('serviceAccounts: ', serviceAccounts); - return (