From ebdcccb6ca9b4e100b8d1dbeed8d3af7c78b64f0 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sat, 18 Mar 2023 13:34:06 +0700 Subject: [PATCH] Checkpoint service accounts --- backend/src/controllers/v2/index.ts | 2 + .../controllers/v2/organizationsController.ts | 69 ++++--- .../v2/serviceAccountsController.ts | 178 ++++++++++++++++++ backend/src/index.ts | 2 + .../dto/CreateServiceAccountDto.ts | 8 + .../interfaces/serviceAccounts/dto/index.ts | 5 + backend/src/middleware/index.ts | 2 + .../src/middleware/requireOrganizationAuth.ts | 10 +- .../middleware/requireServiceAccountAuth.ts | 39 ++++ backend/src/models/index.ts | 9 + backend/src/models/permission.ts | 22 --- backend/src/models/serviceAccount.ts | 9 +- backend/src/models/serviceAccountKey.ts | 4 +- .../src/models/serviceAccountPermission.ts | 37 ++++ backend/src/routes/v2/index.ts | 2 + backend/src/routes/v2/organizations.ts | 16 +- backend/src/routes/v2/serviceAccounts.ts | 53 ++++++ backend/src/types/express/index.d.ts | 1 + 18 files changed, 404 insertions(+), 64 deletions(-) create mode 100644 backend/src/controllers/v2/serviceAccountsController.ts create mode 100644 backend/src/interfaces/serviceAccounts/dto/CreateServiceAccountDto.ts create mode 100644 backend/src/interfaces/serviceAccounts/dto/index.ts create mode 100644 backend/src/middleware/requireServiceAccountAuth.ts delete mode 100644 backend/src/models/permission.ts create mode 100644 backend/src/models/serviceAccountPermission.ts create mode 100644 backend/src/routes/v2/serviceAccounts.ts diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index d266ace3f..db78fa503 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -7,6 +7,7 @@ import * as serviceTokenDataController from './serviceTokenDataController'; import * as apiKeyDataController from './apiKeyDataController'; import * as secretController from './secretController'; import * as secretsController from './secretsController'; +import * as serviceAccountsController from './serviceAccountsController'; import * as environmentController from './environmentController'; import * as tagController from './tagController'; @@ -20,6 +21,7 @@ export { apiKeyDataController, secretController, secretsController, + serviceAccountsController, environmentController, tagController } diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index d167e5129..d7e6c1940 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -1,9 +1,11 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { MembershipOrg, Membership, - Workspace + Workspace, + ServiceAccount } from '../../models'; import { deleteMembershipOrg } from '../../helpers/membershipOrg'; import { updateSubscriptionOrgQuantity } from '../../helpers/organization'; @@ -260,37 +262,44 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) => } } */ - let workspaces; - try { - const { organizationId } = req.params; + const { organizationId } = req.params; - const workspacesSet = new Set( - ( - await Workspace.find( - { - organization: organizationId - }, - '_id' - ) - ).map((w) => w._id.toString()) - ); + const workspacesSet = new Set( + ( + await Workspace.find( + { + organization: organizationId + }, + '_id' + ) + ).map((w) => w._id.toString()) + ); - workspaces = ( - await Membership.find({ - user: req.user._id - }).populate('workspace') - ) - .filter((m) => workspacesSet.has(m.workspace._id.toString())) - .map((m) => m.workspace); - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to get organization workspaces' - }); - } - - return res.status(200).send({ + const workspaces = ( + await Membership.find({ + user: req.user._id + }).populate('workspace') + ) + .filter((m) => workspacesSet.has(m.workspace._id.toString())) + .map((m) => m.workspace); + +return res.status(200).send({ workspaces }); +} + +/** + * Return service accounts for organization with id [organizationId] + * @param req + * @param res + */ +export const getOrganizationServiceAccounts = async (req: Request, res: Response) => { + const { organizationId } = req.params; + const serviceAccounts = await ServiceAccount.find({ + organization: new Types.ObjectId(organizationId) + }); + + return res.status(200).send({ + serviceAccounts + }); } \ No newline at end of file diff --git a/backend/src/controllers/v2/serviceAccountsController.ts b/backend/src/controllers/v2/serviceAccountsController.ts new file mode 100644 index 000000000..dff280009 --- /dev/null +++ b/backend/src/controllers/v2/serviceAccountsController.ts @@ -0,0 +1,178 @@ +import { Request, Response } from 'express'; +import { Types } from 'mongoose'; +import { + ServiceAccount, + ServiceAccountKey, + ServiceAccountPermission +} from '../../models'; +import { + CreateServiceAccountDto +} from '../../interfaces/serviceAccounts/dto'; + +/** + * Create a new service account under organization with id [organizationId] + * that has access to workspaces [workspaces] + * @param req + * @param res + * @returns + */ +export const createServiceAccount = async (req: Request, res: Response) => { + const { + organizationId, + name, + publicKey, + expiresIn, + }: CreateServiceAccountDto = req.body; + + let expiresAt; + if (expiresIn) { + expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + } + + const serviceAccount = await new ServiceAccount({ + name, + organization: new Types.ObjectId(organizationId), + user: req.user, + publicKey, + expiresAt + }).save(); + + // await Promise.all( + // workspaces.map(async ({ + // workspaceId, + // environments, + // permissions, + // encryptedKey, + // nonce + // }: { + // workspaceId: string; + // environments: string[]; + // permissions: string[]; + // encryptedKey: string; + // nonce: string; + // }) => { + // const serviceAccountKey = await new ServiceAccountKey({ + // encryptedKey, + // nonce, + // sender: req.user._id, + // serviceAccount: serviceAccount._id, + // workspace: new Types.ObjectId(workspaceId) + // }); + + // console.log('serviceAccountKey: ', serviceAccountKey); + + // await Promise.all( + // permissions.map(async (name: string) => { + // const permission = await new ServiceAccountPermission({ + // serviceAccount: serviceAccount._id, + // name, + // workspace: new Types.ObjectId(workspaceId), + // environments + // }).save(); + + // console.log('permission: ', permission); + // }) + // ); + // }) + // ); + + return res.status(200).send({ + serviceAccount + }); +} + +// /** +// * Add a service account key to service account with id [serviceAccountId] +// * for workspace with id [workspaceId] +// * @param req +// * @param res +// * @returns +// */ +// export const addServiceAccountKey = async (req: Request, res: Response) => { +// const { +// workspaceId, +// encryptedKey, +// nonce +// } = req.body; + +// const serviceAccountKey = await new ServiceAccountKey({ +// encryptedKey, +// nonce, +// sender: req.user._id, +// serviceAccount: req.serviceAccount._d, +// workspace: new Types.ObjectId(workspaceId) +// }).save(); + +// return serviceAccountKey; +// } + +/** + * Delete service account with id [serviceAccountId] + * @param req + * @param res + * @returns + */ +export const deleteServiceAccount = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; + + const serviceAccount = await ServiceAccount.findByIdAndDelete(serviceAccountId); + + await ServiceAccountKey.deleteMany({ + serviceAccount: new Types.ObjectId(serviceAccountId) + }); + + return res.status(200).send({ + serviceAccount + }); +} + +export const addServiceAccountWorkspaceAccess = async (req: Request, res: Response) => { + const { serviceAccountId, workspaceId } = req.params; + const { + encryptedKey, + nonce, + permissions // should contain environments + } = req.body; + + const serviceAccountKey = await new ServiceAccountKey({ + encryptedKey, + nonce, + sender: req.user._id, + serviceAccount: req.serviceAccount._id, + workspace: new Types.ObjectId('workspaceId') + }); + + const serviceAccountPermissions = await Promise.all( + permissions.map + ); +} + +export const deleteServiceAccountWorkspaceAccess = async (req: Request, res: Response) => { + // TODO +} + +// /** +// * Add a service account key to service account with id [serviceAccountId] +// * for workspace with id [workspaceId] +// * @param req +// * @param res +// * @returns +// */ +// export const addServiceAccountKey = async (req: Request, res: Response) => { +// const { +// workspaceId, +// encryptedKey, +// nonce +// } = req.body; + +// const serviceAccountKey = await new ServiceAccountKey({ +// encryptedKey, +// nonce, +// sender: req.user._id, +// serviceAccount: req.serviceAccount._d, +// workspace: new Types.ObjectId(workspaceId) +// }).save(); + +// return serviceAccountKey; +// } \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 64d58cfc5..2c3060242 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -56,6 +56,7 @@ import { secret as v2SecretRouter, // begin to phase out secrets as v2SecretsRouter, serviceTokenData as v2ServiceTokenDataRouter, + serviceAccounts as v2ServiceAccountsRouter, apiKeyData as v2APIKeyDataRouter, environment as v2EnvironmentRouter, tags as v2TagsRouter, @@ -148,6 +149,7 @@ const main = async () => { app.use('/api/v2/secret', v2SecretRouter); // deprecated app.use('/api/v2/secrets', v2SecretsRouter); app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route + app.use('/api/v2/service-accounts', v2ServiceAccountsRouter); // new app.use('/api/v2/api-key', v2APIKeyDataRouter); // api docs diff --git a/backend/src/interfaces/serviceAccounts/dto/CreateServiceAccountDto.ts b/backend/src/interfaces/serviceAccounts/dto/CreateServiceAccountDto.ts new file mode 100644 index 000000000..dcab0f302 --- /dev/null +++ b/backend/src/interfaces/serviceAccounts/dto/CreateServiceAccountDto.ts @@ -0,0 +1,8 @@ +interface CreateServiceAccountDto { + organizationId: string; + name: string; + publicKey: string; + expiresIn: number; +} + +export default CreateServiceAccountDto; \ No newline at end of file diff --git a/backend/src/interfaces/serviceAccounts/dto/index.ts b/backend/src/interfaces/serviceAccounts/dto/index.ts new file mode 100644 index 000000000..916a198a6 --- /dev/null +++ b/backend/src/interfaces/serviceAccounts/dto/index.ts @@ -0,0 +1,5 @@ +import CreateServiceAccountDto from './CreateServiceAccountDto'; + +export { + CreateServiceAccountDto +} \ No newline at end of file diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 7e014103c..103c6fa16 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -10,6 +10,7 @@ import requireIntegrationAuth from './requireIntegrationAuth'; import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizationAuth'; import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; +import requireServiceAccountAuth from './requireServiceAccountAuth'; import requireSecretAuth from './requireSecretAuth'; import requireSecretsAuth from './requireSecretsAuth'; import validateRequest from './validateRequest'; @@ -27,6 +28,7 @@ export { requireIntegrationAuthorizationAuth, requireServiceTokenAuth, requireServiceTokenDataAuth, + requireServiceAccountAuth, requireSecretAuth, requireSecretsAuth, validateRequest diff --git a/backend/src/middleware/requireOrganizationAuth.ts b/backend/src/middleware/requireOrganizationAuth.ts index 04542b429..d001d03fe 100644 --- a/backend/src/middleware/requireOrganizationAuth.ts +++ b/backend/src/middleware/requireOrganizationAuth.ts @@ -2,6 +2,8 @@ import { Request, Response, NextFunction } from 'express'; import { IOrganization, MembershipOrg } from '../models'; import { UnauthorizedRequestError, ValidationError } from '../utils/errors'; +type req = 'params' | 'body' | 'query'; + /** * Validate if user on request is a member with proper roles for organization * on request params. @@ -11,18 +13,22 @@ import { UnauthorizedRequestError, ValidationError } from '../utils/errors'; */ const requireOrganizationAuth = ({ acceptedRoles, - acceptedStatuses + acceptedStatuses, + location = 'params' }: { acceptedRoles: string[]; acceptedStatuses: string[]; + location?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { // organization authorization middleware + + const { organizationId } = req[location]; // validate organization membership const membershipOrg = await MembershipOrg.findOne({ user: req.user._id, - organization: req.params.organizationId + organization: organizationId }).populate<{ organization: IOrganization }>('organization'); diff --git a/backend/src/middleware/requireServiceAccountAuth.ts b/backend/src/middleware/requireServiceAccountAuth.ts new file mode 100644 index 000000000..bc2cf2d12 --- /dev/null +++ b/backend/src/middleware/requireServiceAccountAuth.ts @@ -0,0 +1,39 @@ +import { Request, Response, NextFunction } from 'express'; +import { ServiceAccount } from '../models'; +import { + AccountNotFoundError, + UnauthorizedRequestError +} from '../utils/errors'; + +type req = 'params' | 'body' | 'query'; + +const requireServiceAccountAuth = ({ + acceptedRoles, + acceptedStatuses, + location = 'params' +}: { + acceptedRoles: string[]; + acceptedStatuses: string[]; + location?: req; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + const serviceAccountId = req[location].serviceAccountId; + const serviceAccount = await ServiceAccount.findById(serviceAccountId); + + // TODO: acceptedRoles and acceptedStatuses + + if (!serviceAccount) { + return next(AccountNotFoundError({ message: 'Failed to locate Service Account' })); + } + + if (serviceAccount.user.toString() !== req.user.id.toString()) { + return next(UnauthorizedRequestError({ message: 'Failed to authenticate the Service Account' })); + } + + req.serviceAccount = serviceAccount; + + next(); + } +} + +export default requireServiceAccountAuth; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index ead32aae4..a7796c6c6 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -10,6 +10,9 @@ import MembershipOrg, { IMembershipOrg } from './membershipOrg'; import Organization, { IOrganization } from './organization'; import Secret, { ISecret } from './secret'; import ServiceToken, { IServiceToken } from './serviceToken'; +import ServiceAccount, { IServiceAccount } from './serviceAccount'; // new +import ServiceAccountKey, { IServiceAccountKey } from './serviceAccountKey'; // new +import ServiceAccountPermission, { IServiceAccountPermission } from './serviceAccountPermission'; import TokenData, { ITokenData } from './tokenData'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; @@ -43,6 +46,12 @@ export { ISecret, ServiceToken, IServiceToken, + ServiceAccount, + IServiceAccount, + ServiceAccountKey, + IServiceAccountKey, + ServiceAccountPermission, + IServiceAccountPermission, TokenData, ITokenData, User, diff --git a/backend/src/models/permission.ts b/backend/src/models/permission.ts deleted file mode 100644 index 6218aaf3a..000000000 --- a/backend/src/models/permission.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Schema, model, Types, Document } from 'mongoose'; - -export interface IPermission extends Document { - _id: Types.ObjectId; - name: string; -} - -const permissionSchema = new Schema( - { - name: { - type: String, - required: true - } - }, - { - timestamps: true - } -); - -const Permission = model('Permission', permissionSchema); - -export default Permission; \ No newline at end of file diff --git a/backend/src/models/serviceAccount.ts b/backend/src/models/serviceAccount.ts index 6ce62f3af..1aecc4d29 100644 --- a/backend/src/models/serviceAccount.ts +++ b/backend/src/models/serviceAccount.ts @@ -3,9 +3,8 @@ import { Schema, model, Types, Document } from 'mongoose'; export interface IServiceAccount extends Document { _id: Types.ObjectId; name: string; - isActive: boolean; organization: Types.ObjectId; - createdBy: Types.ObjectId; + user: Types.ObjectId; publicKey: string; expiresAt: Date; } @@ -16,16 +15,12 @@ const serviceAccountSchema = new Schema( type: String, required: true }, - isActive: { - type: Boolean, - required: true - }, organization: { type: Schema.Types.ObjectId, ref: 'Organization', required: true }, - createdBy: { + user: { // user who created the service account type: Schema.Types.ObjectId, ref: 'User', required: true diff --git a/backend/src/models/serviceAccountKey.ts b/backend/src/models/serviceAccountKey.ts index e54d176d6..637ac188b 100644 --- a/backend/src/models/serviceAccountKey.ts +++ b/backend/src/models/serviceAccountKey.ts @@ -9,7 +9,7 @@ export interface IServiceAccountKey { workspace: Types.ObjectId; } -const serviceAccountSchema = new Schema( +const serviceAccountKeySchema = new Schema( { encryptedKey: { type: String, @@ -39,6 +39,6 @@ const serviceAccountSchema = new Schema( } ); -const ServiceAccountKey = model('ServiceAccountKey', serviceAccountSchema); +const ServiceAccountKey = model('ServiceAccountKey', serviceAccountKeySchema); export default ServiceAccountKey; diff --git a/backend/src/models/serviceAccountPermission.ts b/backend/src/models/serviceAccountPermission.ts new file mode 100644 index 000000000..e51ef4df4 --- /dev/null +++ b/backend/src/models/serviceAccountPermission.ts @@ -0,0 +1,37 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface IServiceAccountPermission extends Document { + _id: Types.ObjectId; + serviceAccount: Types.ObjectId; + name: string; + workspace?: Types.ObjectId; + environment?: string; +} + +const serviceAccountPermissionSchema = new Schema( + { + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount', + required: true + }, + name: { + type: String, + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + }, + environment: { + type: 'String' + } + }, + { + timestamps: true + } +); + +const ServiceAccountPermission = model('ServiceAccountPermission', serviceAccountPermissionSchema); + +export default ServiceAccountPermission; \ No newline at end of file diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index 538ee4800..c088771eb 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -6,6 +6,7 @@ import workspace from './workspace'; import secret from './secret'; // deprecated import secrets from './secrets'; import serviceTokenData from './serviceTokenData'; +import serviceAccounts from './serviceAccounts'; import apiKeyData from './apiKeyData'; import environment from "./environment" import tags from "./tags" @@ -19,6 +20,7 @@ export { secret, secrets, serviceTokenData, + serviceAccounts, apiKeyData, environment, tags diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts index e1488e8a7..a99de1ec9 100644 --- a/backend/src/routes/v2/organizations.ts +++ b/backend/src/routes/v2/organizations.ts @@ -6,7 +6,7 @@ import { requireMembershipOrgAuth, validateRequest } from '../../middleware'; -import { body, param, query } from 'express-validator'; +import { body, param } from 'express-validator'; import { OWNER, ADMIN, MEMBER, ACCEPTED } from '../../variables'; import { organizationsController } from '../../controllers/v2'; @@ -77,4 +77,18 @@ router.get( organizationsController.getOrganizationWorkspaces ); +router.get( + '/:organizationId/service-accounts', + param('organizationId').exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + organizationsController.getOrganizationServiceAccounts +); + export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/serviceAccounts.ts b/backend/src/routes/v2/serviceAccounts.ts new file mode 100644 index 000000000..901fae5f4 --- /dev/null +++ b/backend/src/routes/v2/serviceAccounts.ts @@ -0,0 +1,53 @@ +import express from 'express'; +const router = express.Router(); +import { + requireOrganizationAuth, + requireServiceAccountAuth +} from '../../middleware'; +import { body } from 'express-validator'; +import { + OWNER, + ADMIN, + MEMBER, + ACCEPTED +} from '../../variables'; +import { serviceAccountsController } from '../../controllers/v2'; + +router.post( + '/', + body('organizationId').exists().isString().trim(), + body('name').exists().isString().trim(), + body('publicKey').exists().isString().trim(), + body('expiresIn'), // measured in ms + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED], + location: 'body' + }), + serviceAccountsController.createServiceAccount +); + +// router.post( +// '/:serviceAccountId/key', +// body('workspaceId').exists().isString().trim(), +// body('encryptedKey').exists().isString().trim(), +// body('nonce').exists().isString().trim(), +// requireServiceAccountAuth({ +// acceptedRoles: [OWNER, ADMIN, MEMBER], +// acceptedStatuses: [ACCEPTED] +// }), +// serviceAccountsController.addServiceAccountKey +// ); + +router.delete( + '/:serviceAccountId/key/:serviceAccountKeyId', + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + async (req, res) => { + // TODO: delete service account key id + } +); + +export default router; \ No newline at end of file diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index acee877bd..74833daf0 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -19,6 +19,7 @@ declare global { secrets: any; secretSnapshot: any; serviceToken: any; + serviceAccount: any; accessToken: any; serviceTokenData: any; apiKeyData: any;