From 273f4228d7622509c02a9125363e8e6df72b5182 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 17 Mar 2023 10:43:14 +0700 Subject: [PATCH 01/16] Init models for permission, service account, and service account key --- backend/src/models/permission.ts | 22 ++++++++++++ backend/src/models/serviceAccount.ts | 48 +++++++++++++++++++++++++ backend/src/models/serviceAccountKey.ts | 44 +++++++++++++++++++++++ docker-compose.dev.yml | 6 ++-- 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 backend/src/models/permission.ts create mode 100644 backend/src/models/serviceAccount.ts create mode 100644 backend/src/models/serviceAccountKey.ts diff --git a/backend/src/models/permission.ts b/backend/src/models/permission.ts new file mode 100644 index 000000000..6218aaf3a --- /dev/null +++ b/backend/src/models/permission.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..6ce62f3af --- /dev/null +++ b/backend/src/models/serviceAccount.ts @@ -0,0 +1,48 @@ +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; + publicKey: string; + expiresAt: Date; +} + +const serviceAccountSchema = new Schema( + { + name: { + type: String, + required: true + }, + isActive: { + type: Boolean, + required: true + }, + organization: { + type: Schema.Types.ObjectId, + ref: 'Organization', + required: true + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true + }, + publicKey: { + type: String, + required: true + }, + expiresAt: { + type: Date + } + }, + { + timestamps: true + } +); + +const ServiceAccount = model('ServiceAcount', serviceAccountSchema); + +export default ServiceAccount; \ No newline at end of file diff --git a/backend/src/models/serviceAccountKey.ts b/backend/src/models/serviceAccountKey.ts new file mode 100644 index 000000000..e54d176d6 --- /dev/null +++ b/backend/src/models/serviceAccountKey.ts @@ -0,0 +1,44 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface IServiceAccountKey { + _id: Types.ObjectId; + encryptedKey: string; + nonce: string; + sender: Types.ObjectId; + serviceAccount: Types.ObjectId; + workspace: Types.ObjectId; +} + +const serviceAccountSchema = new Schema( + { + encryptedKey: { + type: String, + required: true + }, + nonce: { + type: String, + required: true + }, + sender: { + type: Schema.Types.ObjectId, + required: true + }, + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount', + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + } + }, + { + timestamps: true + } +); + +const ServiceAccountKey = model('ServiceAccountKey', serviceAccountSchema); + +export default ServiceAccountKey; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7e904cd14..287e80945 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -81,9 +81,9 @@ services: - mongo env_file: .env environment: - - ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGO_USERNAME} - - ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGO_PASSWORD} - - ME_CONFIG_MONGODB_URL=mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongo:27017/ + - ME_CONFIG_MONGODB_ADMINUSERNAME=root + - ME_CONFIG_MONGODB_ADMINPASSWORD=example + - ME_CONFIG_MONGODB_URL=mongodb://root:example@mongo:27017/ ports: - 8081:8081 networks: From ebdcccb6ca9b4e100b8d1dbeed8d3af7c78b64f0 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sat, 18 Mar 2023 13:34:06 +0700 Subject: [PATCH 02/16] 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; From 8fb473c57c684e22ecc82705e3425d586a287896 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sat, 18 Mar 2023 13:52:38 +0700 Subject: [PATCH 03/16] Checkpoint service accounts --- .../v2/serviceAccountsController.ts | 163 +++++++++--------- 1 file changed, 77 insertions(+), 86 deletions(-) diff --git a/backend/src/controllers/v2/serviceAccountsController.ts b/backend/src/controllers/v2/serviceAccountsController.ts index dff280009..78758d85b 100644 --- a/backend/src/controllers/v2/serviceAccountsController.ts +++ b/backend/src/controllers/v2/serviceAccountsController.ts @@ -38,74 +38,86 @@ export const createServiceAccount = async (req: Request, res: Response) => { 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; +/** + * 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(); + const serviceAccountKey = await new ServiceAccountKey({ + encryptedKey, + nonce, + sender: req.user._id, + serviceAccount: req.serviceAccount._d, + workspace: new Types.ObjectId(workspaceId) + }).save(); -// return serviceAccountKey; -// } + return serviceAccountKey; +} + +/** + * Add a permission to service account with id [serviceAccountId] + * @param req + * @param res + */ +export const addServiceAccountPermission = async (req: Request, res: Response) => { + const { + name, + workspaceId, + environment + } = req.body; // TODO: add DTO + + // TODO: validation? + + const serviceAccountPermission = await new ServiceAccountPermission({ + serviceAccount: req.serviceAccount._id, + name, + workspace: new Types.ObjectId(workspaceId), + environment + }); + + return res.status(200).send({ + serviceAccountPermission + }); +} + +/** + * Delete a permission from service account with id [serviceAccountId] + * @param req + * @param res + */ +export const deleteServiceAccountPermission = async (req: Request, res: Response) => { + const { + name, + workspaceId, + environment + } = req.body; // TODO: DTO + + // TODO: how to delete just 1 permission? + const serviceAccountPermission = await ServiceAccountPermission.findOneAndDelete({ + serviceAccount: req.serviceAccount._id, + name, + workspace: new Types.ObjectId(workspaceId), + environment + }); + + return res.status(200).send({ + serviceAccountPermission + }); +} /** * Delete service account with id [serviceAccountId] @@ -121,37 +133,16 @@ export const deleteServiceAccount = async (req: Request, res: Response) => { await ServiceAccountKey.deleteMany({ serviceAccount: new Types.ObjectId(serviceAccountId) }); + + await ServiceAccountPermission.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] From 3a0ce7c084a34eb17930503132485b42e037b164 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 19 Mar 2023 17:01:36 +0700 Subject: [PATCH 04/16] Service account checkpoint --- .../v2/serviceAccountsController.ts | 101 ++++++++-------- backend/src/helpers/membership.ts | 32 +++-- backend/src/helpers/membershipOrg.ts | 54 +++++---- backend/src/helpers/serviceAccount.ts | 110 ++++++++++++++++++ .../dto/AddServiceAccountPermissionDto.ts | 7 ++ .../interfaces/serviceAccounts/dto/index.ts | 4 +- .../middleware/requireMembershipOrgAuth.ts | 15 +-- .../src/middleware/requireOrganizationAuth.ts | 33 ++---- .../middleware/requireServiceAccountAuth.ts | 19 ++- .../src/models/serviceAccountPermission.ts | 4 +- backend/src/routes/v2/organizations.ts | 6 +- backend/src/routes/v2/serviceAccounts.ts | 35 +++++- backend/src/utils/errors.ts | 41 ++++++- backend/src/variables/index.ts | 12 +- backend/src/variables/permissions.ts | 19 +++ 15 files changed, 356 insertions(+), 136 deletions(-) create mode 100644 backend/src/helpers/serviceAccount.ts create mode 100644 backend/src/interfaces/serviceAccounts/dto/AddServiceAccountPermissionDto.ts create mode 100644 backend/src/variables/permissions.ts diff --git a/backend/src/controllers/v2/serviceAccountsController.ts b/backend/src/controllers/v2/serviceAccountsController.ts index 78758d85b..7c25390a7 100644 --- a/backend/src/controllers/v2/serviceAccountsController.ts +++ b/backend/src/controllers/v2/serviceAccountsController.ts @@ -6,8 +6,17 @@ import { ServiceAccountPermission } from '../../models'; import { - CreateServiceAccountDto + validateCreateServiceAccountPermission +} from '../../helpers/serviceAccount'; +import { + CreateServiceAccountDto, + AddServiceAccountPermissionDto } from '../../interfaces/serviceAccounts/dto'; +import { + PERMISSION_SA_WORKSPACE_SET, + PERMISSION_SA_SET +} from '../../variables'; +import { ServiceAccountKeyNotFoundError, ValidationError } from '../../utils/errors'; /** * Create a new service account under organization with id [organizationId] @@ -70,22 +79,39 @@ export const addServiceAccountKey = async (req: Request, res: Response) => { /** * Add a permission to service account with id [serviceAccountId] - * @param req - * @param res + * @param req + * @param res */ export const addServiceAccountPermission = async (req: Request, res: Response) => { const { name, workspaceId, environment - } = req.body; // TODO: add DTO + }: AddServiceAccountPermissionDto = req.body; - // TODO: validation? + if (PERMISSION_SA_WORKSPACE_SET.has(name)) { + // case: permission named [name] is workspace-related + + // some such permissions require workspaceId and environment to be present. + + if (!workspaceId || !environment) { + throw ValidationError({ + message: 'Failed validation that is workspace-related permission must specify a workspace and environment' + }); + } else { + const serviceAccountKey = await ServiceAccountKey.findOne({ + serviceAccount: req.serviceAccount._id, + workspace: new Types.ObjectId(workspaceId) + }); + + if (!serviceAccountKey) throw ServiceAccountKeyNotFoundError({ message: 'Failed to find service account key' }); + } + } const serviceAccountPermission = await new ServiceAccountPermission({ serviceAccount: req.serviceAccount._id, name, - workspace: new Types.ObjectId(workspaceId), + workspace: workspaceId ? new Types.ObjectId(workspaceId) : undefined, environment }); @@ -100,19 +126,15 @@ export const addServiceAccountPermission = async (req: Request, res: Response) = * @param res */ export const deleteServiceAccountPermission = async (req: Request, res: Response) => { - const { - name, - workspaceId, - environment - } = req.body; // TODO: DTO + const { serviceAccountPermissionId } = req.params; + + // user must either be an admin/owner of the organization or they must + // have created the service account in the first place to be able to delete it // TODO: how to delete just 1 permission? - const serviceAccountPermission = await ServiceAccountPermission.findOneAndDelete({ - serviceAccount: req.serviceAccount._id, - name, - workspace: new Types.ObjectId(workspaceId), - environment - }); + + + const serviceAccountPermission = await ServiceAccountPermission.findByIdAndDelete(serviceAccountPermissionId); return res.status(200).send({ serviceAccountPermission @@ -130,40 +152,19 @@ export const deleteServiceAccount = async (req: Request, res: Response) => { const serviceAccount = await ServiceAccount.findByIdAndDelete(serviceAccountId); - await ServiceAccountKey.deleteMany({ - serviceAccount: new Types.ObjectId(serviceAccountId) - }); + if (serviceAccount) { + // case: service account with id [serviceAccountId] was deleted + + await ServiceAccountKey.deleteMany({ + serviceAccount: serviceAccount?._id + }); + + await ServiceAccountPermission.deleteMany({ + serviceAccount: new Types.ObjectId(serviceAccountId) + }); + } - await ServiceAccountPermission.deleteMany({ - serviceAccount: new Types.ObjectId(serviceAccountId) - }); - 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; -// } \ No newline at end of file +} \ No newline at end of file diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 406162a8e..fc4a31074 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -1,5 +1,9 @@ import * as Sentry from '@sentry/node'; import { Membership, Key } from '../models'; +import { + MembershipNotFoundError, + BadRequestError +} from '../utils/errors'; /** * Validate that user with id [userId] is a member of workspace with id [workspaceId] @@ -19,23 +23,17 @@ const validateMembership = async ({ acceptedRoles: string[]; }) => { - let membership; - //TODO: Refactor code to take advantage of using RequestError. It's possible to create new types of errors for more detailed errors - try { - membership = await Membership.findOne({ - user: userId, - workspace: workspaceId - }).populate("workspace"); - - if (!membership) throw new Error('Failed to find membership'); - - if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate membership role'); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to validate membership'); + const membership = await Membership.findOne({ + user: userId, + workspace: workspaceId + }).populate("workspace"); + + if (!membership) { + throw MembershipNotFoundError({ message: 'Failed to find workspace membership' }); + } + + if (!acceptedRoles.includes(membership.role)) { + throw BadRequestError({ message: 'Failed to validate workspace membership role' }); } return membership; diff --git a/backend/src/helpers/membershipOrg.ts b/backend/src/helpers/membershipOrg.ts index 4de4be82d..65c1e01c2 100644 --- a/backend/src/helpers/membershipOrg.ts +++ b/backend/src/helpers/membershipOrg.ts @@ -1,40 +1,48 @@ import * as Sentry from '@sentry/node'; import { Types } from 'mongoose'; import { MembershipOrg, Workspace, Membership, Key } from '../models'; +import { + MembershipOrgNotFoundError, + BadRequestError +} from '../utils/errors'; /** * Validate that user with id [userId] is a member of organization with id [organizationId] * and has at least one of the roles in [acceptedRoles] - * + * @param {Object} obj + * @param {Types.ObjectId} obj.userId + * @param {Types.ObjectId} obj.organizationId + * @param {String[]} obj.acceptedRoles */ -const validateMembership = async ({ +const validateMembershipOrg = async ({ userId, organizationId, - acceptedRoles + acceptedRoles, + acceptedStatuses }: { - userId: string; - organizationId: string; + userId: Types.ObjectId; + organizationId: Types.ObjectId; acceptedRoles: string[]; + acceptedStatuses: string[]; }) => { - let membership; - try { - membership = await MembershipOrg.findOne({ - user: new Types.ObjectId(userId), - organization: new Types.ObjectId(organizationId) - }); - - if (!membership) throw new Error('Failed to find organization membership'); - - if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate organization membership role'); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to validate organization membership'); + const membershipOrg = await MembershipOrg.findOne({ + user: userId, + organization: organizationId + }); + + if (!membershipOrg) { + throw MembershipOrgNotFoundError({ message: 'Failed to find organization membership' }); } - return membership; + if (!acceptedRoles.includes(membershipOrg.role)) { + throw BadRequestError({ message: 'Failed to validate organization membership role' }); + } + + if (!acceptedStatuses.includes(membershipOrg.status)) { + throw BadRequestError({ message: 'Failed to validate organization membership status' }); + } + + return membershipOrg; } /** @@ -156,7 +164,7 @@ const deleteMembershipOrg = async ({ }; export { - validateMembership, + validateMembershipOrg, findMembershipOrg, addMembershipsOrg, deleteMembershipOrg diff --git a/backend/src/helpers/serviceAccount.ts b/backend/src/helpers/serviceAccount.ts new file mode 100644 index 000000000..b82d6b3b0 --- /dev/null +++ b/backend/src/helpers/serviceAccount.ts @@ -0,0 +1,110 @@ +import { Types } from 'mongoose'; +import { + Workspace, + ServiceAccount, + ServiceAccountKey +} from '../models'; +import { + WorkspaceNotFoundError, + ServiceAccountNotFoundError, + ServiceAccountKeyNotFoundError +} from '../utils/errors'; +import { + PERMISSION_SA_WORKSPACE_READ, + PERMISSION_SA_WORKSPACE_WRITE, + PERMISSION_SA_SET +} from '../variables'; + +/** + * Validate that user with id [userId] can provision the permission + * named [name] for a service account with id [serviceAccountId] and + * optionally workspace with id [workspaceId] and environment [environment] + * @param {Object} obj + * @param {String} obj.name - name of permission to create + * @param {Types.ObjectId} userId - id of user creating the permission + * @param {Types.ObjectId} serviceAccountId - id of service account that permission will be bound to + * @param {Types.ObjectId} workspaceId - id of workspace that permission concerns + * @param {Types.ObjectId} workspaceId - id of service account that permission will be bound to + */ +const validateCreateServiceAccountPermission = async ({ + name, + userId, + serviceAccountId, + workspaceId, + environment +}: { + name: string; + userId: Types.ObjectId; + serviceAccountId: Types.ObjectId, + workspaceId?: Types.ObjectId; + environment?: string; +}) => { + + // TODO: as we upgrade user permissions to be more global, then we should take into account + // the user's permissions as it concerns to being able to interact with service accounts + + if (!PERMISSION_SA_SET.has(name)) throw new Error(`${name} is not a valid permission name`); + + if ([ + PERMISSION_SA_WORKSPACE_READ, + PERMISSION_SA_WORKSPACE_WRITE + ].includes(name)) { + if (workspaceId && environment) { + // case: either workspace id [workspaceId] or environment name [environment] is being passed in + // (i.e. validating a service account permission concerning a workspace and/or environment) + const workspace = await Workspace.findById(workspaceId); + + if (!workspace) { + // case: workspace does not exist + throw WorkspaceNotFoundError({ message: 'Failed to locate workspace' }); + } + + if (!workspace.environments.some((env) => env.slug === environment)) { + // case: environment name [environment] is not a valid environment slug in workspace + throw Error('Failed to locate environment in workspace'); + } + + const serviceAccount = await ServiceAccount.findById(serviceAccountId); + if (!serviceAccount) { + // case: service account does not exist + throw ServiceAccountNotFoundError({ message: 'Failed to locate service account' }); + } + + const serviceAccountKey = await ServiceAccountKey.findOne({ + serviceAccount: serviceAccount._id, + workspace: workspaceId + }); + + if (!serviceAccountKey) { + // case: service account key does not exist + throw ServiceAccountKeyNotFoundError({ message: 'Failed to locate service account key' }); + } + } else { + throw new Error('Failed to validate workspace and environment for workspace-related permission'); + } + } + +} + +const validateDeleteServiceAccountPermission = async ({ + userId, + serviceAccountId, + name, + workspaceId, + environment +}: { + userId: Types.ObjectId; + serviceAccountId: Types.ObjectId; + name: string; + workspaceId: Types.ObjectId; + environment: string; +}) => { + // does the user have the authority to delete the permission? + // does the service account permission exist? + + +} + +export { + validateCreateServiceAccountPermission +} \ No newline at end of file diff --git a/backend/src/interfaces/serviceAccounts/dto/AddServiceAccountPermissionDto.ts b/backend/src/interfaces/serviceAccounts/dto/AddServiceAccountPermissionDto.ts new file mode 100644 index 000000000..6d0954c00 --- /dev/null +++ b/backend/src/interfaces/serviceAccounts/dto/AddServiceAccountPermissionDto.ts @@ -0,0 +1,7 @@ +interface AddServiceAccountPermissionDto { + name: string; + workspaceId?: string; + environment?: string; +} + +export default AddServiceAccountPermissionDto; \ No newline at end of file diff --git a/backend/src/interfaces/serviceAccounts/dto/index.ts b/backend/src/interfaces/serviceAccounts/dto/index.ts index 916a198a6..52d8d8342 100644 --- a/backend/src/interfaces/serviceAccounts/dto/index.ts +++ b/backend/src/interfaces/serviceAccounts/dto/index.ts @@ -1,5 +1,7 @@ import CreateServiceAccountDto from './CreateServiceAccountDto'; +import AddServiceAccountPermissionDto from './AddServiceAccountPermissionDto'; export { - CreateServiceAccountDto + CreateServiceAccountDto, + AddServiceAccountPermissionDto } \ No newline at end of file diff --git a/backend/src/middleware/requireMembershipOrgAuth.ts b/backend/src/middleware/requireMembershipOrgAuth.ts index ec1a4d4f1..ea9ed9afc 100644 --- a/backend/src/middleware/requireMembershipOrgAuth.ts +++ b/backend/src/middleware/requireMembershipOrgAuth.ts @@ -3,7 +3,7 @@ import { UnauthorizedRequestError } from '../utils/errors'; import { MembershipOrg } from '../models'; -import { validateMembership } from '../helpers/membershipOrg'; +import { validateMembershipOrg } from '../helpers/membershipOrg'; type req = 'params' | 'body' | 'query'; @@ -17,9 +17,11 @@ type req = 'params' | 'body' | 'query'; */ const requireMembershipOrgAuth = ({ acceptedRoles, + acceptedStatuses, location = 'params' }: { acceptedRoles: string[]; + acceptedStatuses: string[]; location?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { @@ -29,14 +31,13 @@ const requireMembershipOrgAuth = ({ if (!membershipOrg) throw new Error('Failed to find target organization membership'); - const targetMembership = await validateMembership({ - userId: req.user._id.toString(), - organizationId: membershipOrg.organization.toString(), - acceptedRoles + req.targetMembership = await validateMembershipOrg({ + userId: req.user._id, + organizationId: membershipOrg.organization, + acceptedRoles, + acceptedStatuses }); - req.targetMembership = targetMembership; - return next(); } catch (err) { return next(UnauthorizedRequestError({ diff --git a/backend/src/middleware/requireOrganizationAuth.ts b/backend/src/middleware/requireOrganizationAuth.ts index d001d03fe..5768b4b36 100644 --- a/backend/src/middleware/requireOrganizationAuth.ts +++ b/backend/src/middleware/requireOrganizationAuth.ts @@ -1,6 +1,8 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { IOrganization, MembershipOrg } from '../models'; import { UnauthorizedRequestError, ValidationError } from '../utils/errors'; +import { validateMembershipOrg } from '../helpers/membershipOrg'; type req = 'params' | 'body' | 'query'; @@ -9,7 +11,7 @@ type req = 'params' | 'body' | 'query'; * on request params. * @param {Object} obj * @param {String[]} obj.acceptedRoles - accepted organization roles - * @param {String[]} obj.acceptedStatuses - accepted organization statuses + * @param {String[]} obj.accepteStatuses - accepted organization statuses */ const requireOrganizationAuth = ({ acceptedRoles, @@ -21,30 +23,13 @@ const requireOrganizationAuth = ({ 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: organizationId - }).populate<{ organization: IOrganization }>('organization'); - - - if (!membershipOrg) { - return next(UnauthorizedRequestError({message: "You're not a member of this Organization."})) - } - //TODO is this important to validate? I mean is it possible to save wrong role to database or get wrong role from databse? - Zamion101 - if (!acceptedRoles.includes(membershipOrg.role)) { - return next(ValidationError({message: 'Failed to validate Organization Membership Role'})) - } - - if (!acceptedStatuses.includes(membershipOrg.status)) { - return next(ValidationError({message: 'Failed to validate Organization Membership Status'})) - } - - req.membershipOrg = membershipOrg; + req.membershipOrg = await validateMembershipOrg({ + userId: req.user._id, + organizationId: new Types.ObjectId(organizationId), + acceptedRoles, + acceptedStatuses + }); return next(); }; diff --git a/backend/src/middleware/requireServiceAccountAuth.ts b/backend/src/middleware/requireServiceAccountAuth.ts index bc2cf2d12..f5bdb8f3f 100644 --- a/backend/src/middleware/requireServiceAccountAuth.ts +++ b/backend/src/middleware/requireServiceAccountAuth.ts @@ -1,9 +1,11 @@ import { Request, Response, NextFunction } from 'express'; import { ServiceAccount } from '../models'; import { - AccountNotFoundError, - UnauthorizedRequestError + ServiceAccountNotFoundError } from '../utils/errors'; +import { + validateMembershipOrg +} from '../helpers/membershipOrg'; type req = 'params' | 'body' | 'query'; @@ -20,14 +22,19 @@ const requireServiceAccountAuth = ({ 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' })); + return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account' })); } if (serviceAccount.user.toString() !== req.user.id.toString()) { - return next(UnauthorizedRequestError({ message: 'Failed to authenticate the Service Account' })); + // case: creator of the service account is different from + // the user on the request -> apply middleware role/status validation + await validateMembershipOrg({ + userId: req.user._id, + organizationId: serviceAccount.organization, + acceptedRoles, + acceptedStatuses + }); } req.serviceAccount = serviceAccount; diff --git a/backend/src/models/serviceAccountPermission.ts b/backend/src/models/serviceAccountPermission.ts index e51ef4df4..2199557da 100644 --- a/backend/src/models/serviceAccountPermission.ts +++ b/backend/src/models/serviceAccountPermission.ts @@ -22,9 +22,11 @@ const serviceAccountPermissionSchema = new Schema( workspace: { type: Schema.Types.ObjectId, ref: 'Workspace', + default: null }, environment: { - type: 'String' + type: 'String', + default: null } }, { diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts index a99de1ec9..21e2f9cf1 100644 --- a/backend/src/routes/v2/organizations.ts +++ b/backend/src/routes/v2/organizations.ts @@ -40,7 +40,8 @@ router.patch( acceptedStatuses: [ACCEPTED] }), requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN] + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] }), organizationsController.updateOrganizationMembership ); @@ -58,7 +59,8 @@ router.delete( acceptedStatuses: [ACCEPTED] }), requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN] + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] }), organizationsController.deleteOrganizationMembership ); diff --git a/backend/src/routes/v2/serviceAccounts.ts b/backend/src/routes/v2/serviceAccounts.ts index 901fae5f4..73461196d 100644 --- a/backend/src/routes/v2/serviceAccounts.ts +++ b/backend/src/routes/v2/serviceAccounts.ts @@ -2,14 +2,17 @@ import express from 'express'; const router = express.Router(); import { requireOrganizationAuth, - requireServiceAccountAuth + requireWorkspaceAuth, + requireServiceAccountAuth, + validateRequest } from '../../middleware'; import { body } from 'express-validator'; import { OWNER, ADMIN, MEMBER, - ACCEPTED + ACCEPTED, + PERMISSION_SA_SET } from '../../variables'; import { serviceAccountsController } from '../../controllers/v2'; @@ -27,6 +30,19 @@ router.post( serviceAccountsController.createServiceAccount ); +router.post( + '/serviceAccountId/:serviceAccountId/permissions', + body('name').exists().isString().trim().custom((value) => PERMISSION_SA_SET.has(value)), + body('workspaceId').optional().isMongoId(), + body('environment').optional(), + validateRequest, + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.addServiceAccountPermission +); + // router.post( // '/:serviceAccountId/key', // body('workspaceId').exists().isString().trim(), @@ -42,7 +58,7 @@ router.post( router.delete( '/:serviceAccountId/key/:serviceAccountKeyId', requireServiceAccountAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedRoles: [OWNER, ADMIN], acceptedStatuses: [ACCEPTED] }), async (req, res) => { @@ -50,4 +66,17 @@ router.delete( } ); +// TODO: create service account permission +// router.post( + +// ); + +// TODO: delete service account permission + +router.delete( + '/:serviceAccountId/service-account-permission/:serviceAccountPermissionId', + +) + + export default router; \ No newline at end of file diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts index e9972edf5..4799728b9 100644 --- a/backend/src/utils/errors.ts +++ b/backend/src/utils/errors.ts @@ -93,6 +93,16 @@ export const WorkspaceNotFoundError = (error?: Partial) => stack: error?.stack }); +//* ----->[WORKSPACE MEMBERSHIP ERRORS]<----- +export const MembershipNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'workspace_membership_not_found_error', + message: error?.message ?? 'The requested membership was not found', + context: error?.context, + stack: error?.stack +}); + //* ----->[ORGANIZATION ERRORS]<----- export const OrganizationNotFoundError = (error?: Partial) => new RequestError({ logLevel: error?.logLevel ?? LogLevel.ERROR, @@ -103,6 +113,16 @@ export const OrganizationNotFoundError = (error?: Partial) stack: error?.stack }); +//* ----->[MEMBERSHIP ORGANIZATION ERRORS]<----- +export const MembershipOrgNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'organization_membership_not_found_error', + message: error?.message ?? 'The requested organization membership was not found', + context: error?.context, + stack: error?.stack +}); + //* ----->[ACCOUNT ERRORS]<----- export const AccountNotFoundError = (error?: Partial) => new RequestError({ logLevel: error?.logLevel ?? LogLevel.ERROR, @@ -157,10 +177,29 @@ export const ServiceTokenDataNotFoundError = (error?: Partial) => new RequestError({ logLevel: error?.logLevel ?? LogLevel.ERROR, statusCode: error?.statusCode ?? 404, - type: error?.type ?? 'service_token_data_not_found_error', + type: error?.type ?? 'api_key_data_not_found_error', message: error?.message ?? 'The requested service token data was not found', context: error?.context, stack: error?.stack +}); + +//* ----->[SERVICE_ACCOUNT ERRORS]<----- +export const ServiceAccountNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'service_account_not_found_error', + message: error?.message ?? 'The requested service account was not found', + context: error?.context, + stack: error?.stack +}); + +export const ServiceAccountKeyNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'service_account_key_not_found_error', + message: error?.message ?? 'The requested service account key was not found', + context: error?.context, + stack: error?.stack }) //* ----->[MISC ERRORS]<----- diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index b71044cba..64eceb786 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -63,6 +63,12 @@ import { TOKEN_EMAIL_ORG_INVITATION, TOKEN_EMAIL_PASSWORD_RESET } from './token'; +import { + PERMISSION_SA_WORKSPACE_READ, + PERMISSION_SA_WORKSPACE_WRITE, + PERMISSION_SA_WORKSPACE_SET, + PERMISSION_SA_SET +} from './permissions'; export { OWNER, @@ -124,5 +130,9 @@ export { TOKEN_EMAIL_CONFIRMATION, TOKEN_EMAIL_MFA, TOKEN_EMAIL_ORG_INVITATION, - TOKEN_EMAIL_PASSWORD_RESET + TOKEN_EMAIL_PASSWORD_RESET, + PERMISSION_SA_WORKSPACE_READ, + PERMISSION_SA_WORKSPACE_WRITE, + PERMISSION_SA_WORKSPACE_SET, + PERMISSION_SA_SET }; diff --git a/backend/src/variables/permissions.ts b/backend/src/variables/permissions.ts new file mode 100644 index 000000000..f553c88e3 --- /dev/null +++ b/backend/src/variables/permissions.ts @@ -0,0 +1,19 @@ +const PERMISSION_SA_WORKSPACE_READ = 'read'; +const PERMISSION_SA_WORKSPACE_WRITE = 'write'; + +const PERMISSION_SA_WORKSPACE_SET = new Set([ + PERMISSION_SA_WORKSPACE_READ, + PERMISSION_SA_WORKSPACE_WRITE +]); + +const PERMISSION_SA_SET = new Set([ + PERMISSION_SA_WORKSPACE_READ, + PERMISSION_SA_WORKSPACE_WRITE +]); + +export { + PERMISSION_SA_WORKSPACE_READ, + PERMISSION_SA_WORKSPACE_WRITE, + PERMISSION_SA_WORKSPACE_SET, + PERMISSION_SA_SET +} \ No newline at end of file From d36d7bfce6c403ad2087d5e1c550c55dbee9adfa Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 27 Mar 2023 22:00:10 +0700 Subject: [PATCH 05/16] Checkpoint service account functionality, added UI and general backend structure --- .../controllers/v2/organizationsController.ts | 1 + .../v2/serviceAccountsController.ts | 232 ++++++++--- backend/src/helpers/serviceAccount.ts | 110 ----- backend/src/middleware/index.ts | 2 + ...eServiceAccountWorkspacePermissionsAuth.ts | 52 +++ backend/src/models/index.ts | 9 +- backend/src/models/serviceAccount.ts | 6 + .../serviceAccountOrganizationPermission.ts | 28 ++ .../src/models/serviceAccountPermission.ts | 39 -- .../serviceAccountWorkspacePermissions.ts | 54 +++ backend/src/routes/v2/serviceAccounts.ts | 164 ++++++-- backend/src/services/smtp.ts | 2 +- backend/src/variables/index.ts | 12 +- backend/src/variables/permissions.ts | 19 - frontend/src/components/basic/Layout.tsx | 1 + .../src/components/navigation/NavHeader.tsx | 15 +- .../utilities/cryptography/crypto.ts | 19 +- frontend/src/hooks/api/index.tsx | 1 + .../src/hooks/api/serviceAccounts/index.tsx | 9 + .../src/hooks/api/serviceAccounts/queries.tsx | 146 +++++++ .../src/hooks/api/serviceAccounts/types.ts | 66 +++ frontend/src/layouts/AppLayout/AppLayout.tsx | 33 +- .../settings/org/{[id].tsx => [id]/index.tsx} | 0 .../service-accounts/[serviceAccountId].tsx | 33 ++ .../pages/settings/service-account/[id].tsx | 20 + .../CreateServiceAccountPage.tsx | 43 ++ .../SAProjectLevelPermissionsTable.tsx | 394 ++++++++++++++++++ .../SAProjectLevelPermissionsTable/index.tsx | 1 + .../ServiceAccountNameChangeSection.tsx | 88 ++++ .../ServiceAccountNameChangeSection/index.tsx | 1 + .../components/index.tsx | 2 + .../CreateServiceAccountPage/index.tsx | 1 + .../OrgSettingsPage/OrgSettingsPage.tsx | 36 +- .../OrgIncidentContactsTable.tsx | 3 +- .../OrgMembersTable/OrgMembersTable.tsx | 28 +- .../OrgServiceAccountsTable.tsx | 353 ++++++++++++++++ .../OrgServiceAccountsTable/index.tsx | 1 + .../OrgSettingsPage/components/index.tsx | 2 + .../ServiceTokenSection.tsx | 40 +- 39 files changed, 1710 insertions(+), 356 deletions(-) create mode 100644 backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts create mode 100644 backend/src/models/serviceAccountOrganizationPermission.ts delete mode 100644 backend/src/models/serviceAccountPermission.ts create mode 100644 backend/src/models/serviceAccountWorkspacePermissions.ts delete mode 100644 backend/src/variables/permissions.ts create mode 100644 frontend/src/hooks/api/serviceAccounts/index.tsx create mode 100644 frontend/src/hooks/api/serviceAccounts/queries.tsx create mode 100644 frontend/src/hooks/api/serviceAccounts/types.ts rename frontend/src/pages/settings/org/{[id].tsx => [id]/index.tsx} (100%) create mode 100644 frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx create mode 100644 frontend/src/pages/settings/service-account/[id].tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/index.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index d7e6c1940..613206ba3 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -295,6 +295,7 @@ return res.status(200).send({ */ export const getOrganizationServiceAccounts = async (req: Request, res: Response) => { const { organizationId } = req.params; + const serviceAccounts = await ServiceAccount.find({ organization: new Types.ObjectId(organizationId) }); diff --git a/backend/src/controllers/v2/serviceAccountsController.ts b/backend/src/controllers/v2/serviceAccountsController.ts index 7c25390a7..7d79fb6d7 100644 --- a/backend/src/controllers/v2/serviceAccountsController.ts +++ b/backend/src/controllers/v2/serviceAccountsController.ts @@ -1,34 +1,49 @@ import { Request, Response } from 'express'; import { Types } from 'mongoose'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; import { ServiceAccount, ServiceAccountKey, - ServiceAccountPermission + ServiceAccountOrganizationPermissions, + ServiceAccountWorkspacePermissions } from '../../models'; import { - validateCreateServiceAccountPermission -} from '../../helpers/serviceAccount'; -import { - CreateServiceAccountDto, - AddServiceAccountPermissionDto + CreateServiceAccountDto } from '../../interfaces/serviceAccounts/dto'; -import { - PERMISSION_SA_WORKSPACE_SET, - PERMISSION_SA_SET -} from '../../variables'; -import { ServiceAccountKeyNotFoundError, ValidationError } from '../../utils/errors'; +import { BadRequestError, ServiceAccountNotFoundError } from '../../utils/errors'; +import { getSaltRounds } from '../../config'; + +/** + * Return service account with id [serviceAccountId] + * @param req + * @param res + */ +export const getServiceAccount = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; + + const serviceAccount = await ServiceAccount.findById(serviceAccountId); + + if (!serviceAccount) { + throw ServiceAccountNotFoundError({ message: 'Failed to find service account' }); + } + + return res.status(200).send({ + serviceAccount + }); +} /** * Create a new service account under organization with id [organizationId] * that has access to workspaces [workspaces] * @param req * @param res - * @returns + * @returns */ export const createServiceAccount = async (req: Request, res: Response) => { const { - organizationId, name, + organizationId, publicKey, expiresIn, }: CreateServiceAccountDto = req.body; @@ -38,15 +53,59 @@ export const createServiceAccount = async (req: Request, res: Response) => { expiresAt = new Date(); expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); } + + const secret = crypto.randomBytes(16).toString('base64'); + const secretHash = await bcrypt.hash(secret, getSaltRounds()); + // create service account const serviceAccount = await new ServiceAccount({ name, organization: new Types.ObjectId(organizationId), user: req.user, publicKey, - expiresAt + expiresAt, + secretHash }).save(); + + const serviceAccountObj = serviceAccount.toObject(); + + delete serviceAccountObj.secretHash; + // provision default org-level permissions for service account + const permissions = await new ServiceAccountOrganizationPermissions({ + serviceAccount: serviceAccount._id + }).save(); + + const secretId = Buffer.from(serviceAccount._id.toString(), 'hex').toString('base64'); + + return res.status(200).send({ + serviceAccountAccessKey: `SA.${secretId}.${secret}`, + serviceAccount: serviceAccountObj + }); +} + +/** + * Change name of service account with id [serviceAccountId] to [name] + * @param req + * @param res + * @returns + */ +export const changeServiceAccountName = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; + const { name } = req.body; + + const serviceAccount = await ServiceAccount.findOneAndUpdate( + { + _id: new Types.ObjectId(serviceAccountId) + }, + { + name + }, + { + new: true + } + ); + return res.status(200).send({ serviceAccount }); @@ -78,66 +137,111 @@ export const addServiceAccountKey = async (req: Request, res: Response) => { } /** - * Add a permission to service account with id [serviceAccountId] - * @param req - * @param res + * Return organization-level permissions for service account with id [serviceAccountId] + * @param req + * @param res */ -export const addServiceAccountPermission = async (req: Request, res: Response) => { - const { - name, - workspaceId, - environment - }: AddServiceAccountPermissionDto = req.body; +export const getServiceAccountOrganizationPermissions = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; - if (PERMISSION_SA_WORKSPACE_SET.has(name)) { - // case: permission named [name] is workspace-related - - // some such permissions require workspaceId and environment to be present. - - if (!workspaceId || !environment) { - throw ValidationError({ - message: 'Failed validation that is workspace-related permission must specify a workspace and environment' - }); - } else { - const serviceAccountKey = await ServiceAccountKey.findOne({ - serviceAccount: req.serviceAccount._id, - workspace: new Types.ObjectId(workspaceId) - }); - - if (!serviceAccountKey) throw ServiceAccountKeyNotFoundError({ message: 'Failed to find service account key' }); - } - } - - const serviceAccountPermission = await new ServiceAccountPermission({ - serviceAccount: req.serviceAccount._id, - name, - workspace: workspaceId ? new Types.ObjectId(workspaceId) : undefined, - environment + const permissions = await ServiceAccountOrganizationPermissions.findOne({ + serviceAccount: new Types.ObjectId(serviceAccountId), }); return res.status(200).send({ - serviceAccountPermission + permissions }); } /** - * Delete a permission from service account with id [serviceAccountId] + * Return workspace-level permissions for service account with id [serviceAccountId] * @param req * @param res */ -export const deleteServiceAccountPermission = async (req: Request, res: Response) => { - const { serviceAccountPermissionId } = req.params; - - // user must either be an admin/owner of the organization or they must - // have created the service account in the first place to be able to delete it - - // TODO: how to delete just 1 permission? - - - const serviceAccountPermission = await ServiceAccountPermission.findByIdAndDelete(serviceAccountPermissionId); +export const getServiceAccountWorkspacePermissions = async (req: Request, res: Response) => { + const permissions = await ServiceAccountWorkspacePermissions.find({ + serviceAccount: req.serviceAccount._id + }).populate('workspace'); return res.status(200).send({ - serviceAccountPermission + permissions + }); +} + +/** + * 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 + + return res.status(200).send({ + permissions + }); +} + +/** + * Add a workspace permissions to service account with id [serviceAccountId] + * @param req + * @param res + */ +export const addServiceAccountWorkspacePermission = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; + const { + environment, + workspaceId, + canRead = false, + canWrite = false, + canUpdate = false, + canDelete = false + } = req.body; + + if (!req.membership.workspace.environments.some((e: { name: string; slug: string }) => e.slug === environment)) { + return res.status(400).send({ + message: 'Failed to validate workspace environment' + }); + } + + const existingPermission = await ServiceAccountWorkspacePermissions.findOne({ + serviceAccount: new Types.ObjectId(serviceAccountId), + workspaceId: new Types.ObjectId(workspaceId), + environment + }); + + if (existingPermission) throw BadRequestError({ message: 'Failed to add workspace permission to service account due to already-existing ' }); + + const permissions = await new ServiceAccountWorkspacePermissions({ + serviceAccount: new Types.ObjectId(serviceAccountId), + workspace: new Types.ObjectId(workspaceId), + environment, + canRead, + canWrite, + canUpdate, + canDelete + }).save(); + + return res.status(200).send({ + permissions + }); +} + +/** + * Delete workspace permissions 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); + + return res.status(200).send({ + permissions }); } @@ -156,10 +260,14 @@ export const deleteServiceAccount = async (req: Request, res: Response) => { // case: service account with id [serviceAccountId] was deleted await ServiceAccountKey.deleteMany({ - serviceAccount: serviceAccount?._id + serviceAccount: serviceAccount._id }); - await ServiceAccountPermission.deleteMany({ + await ServiceAccountOrganizationPermissions.deleteMany({ + serviceAccount: new Types.ObjectId(serviceAccountId) + }); + + await ServiceAccountWorkspacePermissions.deleteMany({ serviceAccount: new Types.ObjectId(serviceAccountId) }); } diff --git a/backend/src/helpers/serviceAccount.ts b/backend/src/helpers/serviceAccount.ts index b82d6b3b0..e69de29bb 100644 --- a/backend/src/helpers/serviceAccount.ts +++ b/backend/src/helpers/serviceAccount.ts @@ -1,110 +0,0 @@ -import { Types } from 'mongoose'; -import { - Workspace, - ServiceAccount, - ServiceAccountKey -} from '../models'; -import { - WorkspaceNotFoundError, - ServiceAccountNotFoundError, - ServiceAccountKeyNotFoundError -} from '../utils/errors'; -import { - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE, - PERMISSION_SA_SET -} from '../variables'; - -/** - * Validate that user with id [userId] can provision the permission - * named [name] for a service account with id [serviceAccountId] and - * optionally workspace with id [workspaceId] and environment [environment] - * @param {Object} obj - * @param {String} obj.name - name of permission to create - * @param {Types.ObjectId} userId - id of user creating the permission - * @param {Types.ObjectId} serviceAccountId - id of service account that permission will be bound to - * @param {Types.ObjectId} workspaceId - id of workspace that permission concerns - * @param {Types.ObjectId} workspaceId - id of service account that permission will be bound to - */ -const validateCreateServiceAccountPermission = async ({ - name, - userId, - serviceAccountId, - workspaceId, - environment -}: { - name: string; - userId: Types.ObjectId; - serviceAccountId: Types.ObjectId, - workspaceId?: Types.ObjectId; - environment?: string; -}) => { - - // TODO: as we upgrade user permissions to be more global, then we should take into account - // the user's permissions as it concerns to being able to interact with service accounts - - if (!PERMISSION_SA_SET.has(name)) throw new Error(`${name} is not a valid permission name`); - - if ([ - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE - ].includes(name)) { - if (workspaceId && environment) { - // case: either workspace id [workspaceId] or environment name [environment] is being passed in - // (i.e. validating a service account permission concerning a workspace and/or environment) - const workspace = await Workspace.findById(workspaceId); - - if (!workspace) { - // case: workspace does not exist - throw WorkspaceNotFoundError({ message: 'Failed to locate workspace' }); - } - - if (!workspace.environments.some((env) => env.slug === environment)) { - // case: environment name [environment] is not a valid environment slug in workspace - throw Error('Failed to locate environment in workspace'); - } - - const serviceAccount = await ServiceAccount.findById(serviceAccountId); - if (!serviceAccount) { - // case: service account does not exist - throw ServiceAccountNotFoundError({ message: 'Failed to locate service account' }); - } - - const serviceAccountKey = await ServiceAccountKey.findOne({ - serviceAccount: serviceAccount._id, - workspace: workspaceId - }); - - if (!serviceAccountKey) { - // case: service account key does not exist - throw ServiceAccountKeyNotFoundError({ message: 'Failed to locate service account key' }); - } - } else { - throw new Error('Failed to validate workspace and environment for workspace-related permission'); - } - } - -} - -const validateDeleteServiceAccountPermission = async ({ - userId, - serviceAccountId, - name, - workspaceId, - environment -}: { - userId: Types.ObjectId; - serviceAccountId: Types.ObjectId; - name: string; - workspaceId: Types.ObjectId; - environment: string; -}) => { - // does the user have the authority to delete the permission? - // does the service account permission exist? - - -} - -export { - validateCreateServiceAccountPermission -} \ No newline at end of file diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 103c6fa16..f1069ddd4 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -11,6 +11,7 @@ import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizati import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; import requireServiceAccountAuth from './requireServiceAccountAuth'; +import requireServiceAccountWorkspacePermissionsAuth from './requireServiceAccountWorkspacePermissionsAuth'; import requireSecretAuth from './requireSecretAuth'; import requireSecretsAuth from './requireSecretsAuth'; import validateRequest from './validateRequest'; @@ -29,6 +30,7 @@ export { requireServiceTokenAuth, requireServiceTokenDataAuth, requireServiceAccountAuth, + requireServiceAccountWorkspacePermissionsAuth, requireSecretAuth, requireSecretsAuth, validateRequest diff --git a/backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts b/backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts new file mode 100644 index 000000000..cb6f2980c --- /dev/null +++ b/backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts @@ -0,0 +1,52 @@ +import { Request, Response, NextFunction } from 'express'; +import { ServiceAccount, ServiceAccountWorkspacePermissions } from '../models'; +import { + ServiceAccountNotFoundError +} from '../utils/errors'; +import { + validateMembershipOrg +} from '../helpers/membershipOrg'; + +type req = 'params' | 'body' | 'query'; + +const requireServiceAccountWorkspacePermissionsAuth = ({ + acceptedRoles, + acceptedStatuses, + location = 'params' +}: { + acceptedRoles: string[]; + acceptedStatuses: string[]; + location?: req; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + const serviceAccountWorkspacePermissionsId = req[location].serviceAccountWorkspacePermissionsId; + const serviceAccountWorkspacePermissions = await ServiceAccountWorkspacePermissions.findById(serviceAccountWorkspacePermissionsId); + + if (!serviceAccountWorkspacePermissions) { + return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account workspace permission' })); + } + + const serviceAccount = await ServiceAccount.findById(serviceAccountWorkspacePermissions.serviceAccount); + + if (!serviceAccount) { + return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account' })); + } + + if (serviceAccount.user.toString() !== req.user.id.toString()) { + // case: creator of the service account is different from + // the user on the request -> apply middleware role/status validation + await validateMembershipOrg({ + userId: req.user._id, + organizationId: serviceAccount.organization, + acceptedRoles, + acceptedStatuses + }); + } + + req.serviceAccount = serviceAccount; + + next(); + } +} + +export default requireServiceAccountWorkspacePermissionsAuth; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index a7796c6c6..0474452c7 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -12,7 +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 ServiceAccountPermission, { IServiceAccountPermission } from './serviceAccountPermission'; +import ServiceAccountOrganizationPermissions, { IServiceAccountOrganizationPermissions } from './serviceAccountOrganizationPermission'; // new +import ServiceAccountWorkspacePermissions, { IServiceAccountWorkspacePermissions } from './serviceAccountWorkspacePermissions'; // new import TokenData, { ITokenData } from './tokenData'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; @@ -50,8 +51,10 @@ export { IServiceAccount, ServiceAccountKey, IServiceAccountKey, - ServiceAccountPermission, - IServiceAccountPermission, + ServiceAccountOrganizationPermissions, + IServiceAccountOrganizationPermissions, + ServiceAccountWorkspacePermissions, + IServiceAccountWorkspacePermissions, TokenData, ITokenData, User, diff --git a/backend/src/models/serviceAccount.ts b/backend/src/models/serviceAccount.ts index 1aecc4d29..79e99a1eb 100644 --- a/backend/src/models/serviceAccount.ts +++ b/backend/src/models/serviceAccount.ts @@ -7,6 +7,7 @@ export interface IServiceAccount extends Document { user: Types.ObjectId; publicKey: string; expiresAt: Date; + secretHash: string; } const serviceAccountSchema = new Schema( @@ -31,6 +32,11 @@ const serviceAccountSchema = new Schema( }, expiresAt: { type: Date + }, + secretHash: { + type: String, + required: true, + select: false } }, { diff --git a/backend/src/models/serviceAccountOrganizationPermission.ts b/backend/src/models/serviceAccountOrganizationPermission.ts new file mode 100644 index 000000000..e616bfabc --- /dev/null +++ b/backend/src/models/serviceAccountOrganizationPermission.ts @@ -0,0 +1,28 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface IServiceAccountOrganizationPermissions extends Document { + _id: Types.ObjectId; + serviceAccount: Types.ObjectId; + canFoo: boolean; +} + +const serviceAccountOrganizationPermissionsSchema = new Schema( + { + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount', + required: true + }, + canFoo: { + type: Boolean, + default: false + } + }, + { + timestamps: true + } +); + +const ServiceAccountOrganizationPermissions = model('ServiceAccountOrganizationPermissions', serviceAccountOrganizationPermissionsSchema); + +export default ServiceAccountOrganizationPermissions; \ No newline at end of file diff --git a/backend/src/models/serviceAccountPermission.ts b/backend/src/models/serviceAccountPermission.ts deleted file mode 100644 index 2199557da..000000000 --- a/backend/src/models/serviceAccountPermission.ts +++ /dev/null @@ -1,39 +0,0 @@ -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', - default: null - }, - environment: { - type: 'String', - default: null - } - }, - { - timestamps: true - } -); - -const ServiceAccountPermission = model('ServiceAccountPermission', serviceAccountPermissionSchema); - -export default ServiceAccountPermission; \ No newline at end of file diff --git a/backend/src/models/serviceAccountWorkspacePermissions.ts b/backend/src/models/serviceAccountWorkspacePermissions.ts new file mode 100644 index 000000000..ea81867c5 --- /dev/null +++ b/backend/src/models/serviceAccountWorkspacePermissions.ts @@ -0,0 +1,54 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface IServiceAccountWorkspacePermissions extends Document { + _id: Types.ObjectId; + serviceAccount: Types.ObjectId; + workspace: Types.ObjectId; + environment: string; + canRead: boolean; + canWrite: boolean; + canUpdate: boolean; + canDelete: boolean; +} + +const serviceAccountWorkspacePermissions = new Schema( + { + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount', + required: true + }, + workspace:{ + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + environment: { + type: String, + required: true + }, + canRead: { + type: Boolean, + default: false + }, + canWrite: { + type: Boolean, + default: false + }, + canUpdate: { + type: Boolean, + default: false + }, + canDelete: { + type: Boolean, + default: false + } + }, + { + timestamps: true + } +); + +const ServiceAccountWorkspacePermissions = model('ServiceAccountWorkspacePermissions', serviceAccountWorkspacePermissions); + +export default ServiceAccountWorkspacePermissions; \ No newline at end of file diff --git a/backend/src/routes/v2/serviceAccounts.ts b/backend/src/routes/v2/serviceAccounts.ts index 73461196d..9d49985fa 100644 --- a/backend/src/routes/v2/serviceAccounts.ts +++ b/backend/src/routes/v2/serviceAccounts.ts @@ -1,27 +1,45 @@ import express from 'express'; const router = express.Router(); import { + requireAuth, requireOrganizationAuth, requireWorkspaceAuth, requireServiceAccountAuth, + requireServiceAccountWorkspacePermissionsAuth, validateRequest } from '../../middleware'; -import { body } from 'express-validator'; +import { param, query, body } from 'express-validator'; import { OWNER, ADMIN, MEMBER, - ACCEPTED, - PERMISSION_SA_SET + ACCEPTED } from '../../variables'; import { serviceAccountsController } from '../../controllers/v2'; +router.get( + '/:serviceAccountId', + param('serviceAccountId').exists().isString().trim(), + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.getServiceAccount +); + router.post( '/', body('organizationId').exists().isString().trim(), body('name').exists().isString().trim(), body('publicKey').exists().isString().trim(), - body('expiresIn'), // measured in ms + body('expiresIn').isNumeric(), // measured in ms + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], acceptedStatuses: [ACCEPTED], @@ -30,17 +48,119 @@ router.post( serviceAccountsController.createServiceAccount ); -router.post( - '/serviceAccountId/:serviceAccountId/permissions', - body('name').exists().isString().trim().custom((value) => PERMISSION_SA_SET.has(value)), - body('workspaceId').optional().isMongoId(), - body('environment').optional(), +router.patch( + '/:serviceAccountId/name', + param('serviceAccountId').exists().isString().trim(), validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), requireServiceAccountAuth({ acceptedRoles: [OWNER, ADMIN], acceptedStatuses: [ACCEPTED] }), - serviceAccountsController.addServiceAccountPermission + serviceAccountsController.changeServiceAccountName +); + +router.delete( + '/:serviceAccountId', + param('serviceAccountId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + 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/workspace', + param('serviceAccountId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + 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/workspace', + param('serviceAccountId').exists().isString().trim(), + body('workspaceId').exists().isString().notEmpty(), + body('environment').exists().isString().notEmpty(), + body('canRead').isBoolean().optional(), + body('canWrite').isBoolean().optional(), + body('canUpdate').isBoolean().optional(), + body('canDelete').isBoolean().optional(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + serviceAccountsController.addServiceAccountWorkspacePermission +); + +router.delete( + '/:serviceAccountId/permissions/workspace/:serviceAccountWorkspacePermissionsId', + param('serviceAccountId').exists().isString().trim(), + param('serviceAccountWorkspacePermissionsId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireServiceAccountWorkspacePermissionsAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.deleteServiceAccountWorkspacePermission ); // router.post( @@ -55,28 +175,4 @@ router.post( // serviceAccountsController.addServiceAccountKey // ); -router.delete( - '/:serviceAccountId/key/:serviceAccountKeyId', - requireServiceAccountAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED] - }), - async (req, res) => { - // TODO: delete service account key id - } -); - -// TODO: create service account permission -// router.post( - -// ); - -// TODO: delete service account permission - -router.delete( - '/:serviceAccountId/service-account-permission/:serviceAccountPermissionId', - -) - - export default router; \ No newline at end of file diff --git a/backend/src/services/smtp.ts b/backend/src/services/smtp.ts index 7a4ebf00b..b30a43447 100644 --- a/backend/src/services/smtp.ts +++ b/backend/src/services/smtp.ts @@ -66,7 +66,7 @@ export const initSmtp = () => { const transporter = nodemailer.createTransport(mailOpts); transporter .verify() - .then(() => { + .then((err) => { Sentry.setUser(null); Sentry.captureMessage('SMTP - Successfully connected'); }) diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 64eceb786..b71044cba 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -63,12 +63,6 @@ import { TOKEN_EMAIL_ORG_INVITATION, TOKEN_EMAIL_PASSWORD_RESET } from './token'; -import { - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE, - PERMISSION_SA_WORKSPACE_SET, - PERMISSION_SA_SET -} from './permissions'; export { OWNER, @@ -130,9 +124,5 @@ export { TOKEN_EMAIL_CONFIRMATION, TOKEN_EMAIL_MFA, TOKEN_EMAIL_ORG_INVITATION, - TOKEN_EMAIL_PASSWORD_RESET, - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE, - PERMISSION_SA_WORKSPACE_SET, - PERMISSION_SA_SET + TOKEN_EMAIL_PASSWORD_RESET }; diff --git a/backend/src/variables/permissions.ts b/backend/src/variables/permissions.ts deleted file mode 100644 index f553c88e3..000000000 --- a/backend/src/variables/permissions.ts +++ /dev/null @@ -1,19 +0,0 @@ -const PERMISSION_SA_WORKSPACE_READ = 'read'; -const PERMISSION_SA_WORKSPACE_WRITE = 'write'; - -const PERMISSION_SA_WORKSPACE_SET = new Set([ - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE -]); - -const PERMISSION_SA_SET = new Set([ - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE -]); - -export { - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE, - PERMISSION_SA_WORKSPACE_SET, - PERMISSION_SA_SET -} \ No newline at end of file diff --git a/frontend/src/components/basic/Layout.tsx b/frontend/src/components/basic/Layout.tsx index e76b99cc3..4063df46b 100644 --- a/frontend/src/components/basic/Layout.tsx +++ b/frontend/src/components/basic/Layout.tsx @@ -176,6 +176,7 @@ const Layout = ({ children }: LayoutProps) => { useEffect(() => { // Put a user in a workspace if they're not in one yet + const putUserInWorkSpace = async () => { if (tempLocalStorage('orgData.id') === '') { const userOrgs = await getOrganizations(); diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 57a3f96fd..e1ee09080 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -3,21 +3,26 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useOrganization, useWorkspace } from '@app/context'; +// TODO: make links clickable and clean up + /** * This is the component at the top of almost every page. * It shows how to navigate to a certain page. * It future these links should also be clickable and hoverable * @param obj * @param obj.pageName - Name of the page - * @param obj.isProjectRelated - whether this page is related to project or now (determine if it's 2 or 3 navigation steps) + * @param obj.isProjectRelated - whether or not this page is related to project (determine if it's 2 or 3 navigation steps) + * @param obj.isOrganizationRelated - whether or not this page is related to organization (determine if it's 2 or 3 navigation steps) * @returns */ export default function NavHeader({ pageName, - isProjectRelated + isProjectRelated, + isOrganizationRelated }: { pageName: string; isProjectRelated?: boolean; + isOrganizationRelated?: boolean; }): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); @@ -34,6 +39,12 @@ export default function NavHeader({
{currentWorkspace?.name}
)} + {isOrganizationRelated && ( + <> + +
Organization Settings
+ + )}
{pageName}
diff --git a/frontend/src/components/utilities/cryptography/crypto.ts b/frontend/src/components/utilities/cryptography/crypto.ts index 961dee83f..d1a6fb5da 100644 --- a/frontend/src/components/utilities/cryptography/crypto.ts +++ b/frontend/src/components/utilities/cryptography/crypto.ts @@ -5,6 +5,21 @@ import aes from './aes-256-gcm'; const nacl = require('tweetnacl'); nacl.util = require('tweetnacl-util'); +/** + * Return new base64, NaCl, public-private key pair. + * @returns {Object} obj + * @returns {String} obj.publicKey - base64, NaCl, public key + * @returns {String} obj.privateKey - base64, NaCl, private key + */ +const generateKeyPair = () => { + const pair = nacl.box.keyPair(); + + return ({ + publicKey: nacl.util.encodeBase64(pair.publicKey), + privateKey: nacl.util.encodeBase64(pair.secretKey) + }); +} + type EncryptAsymmetricProps = { plaintext: string; publicKey: string; @@ -189,5 +204,5 @@ export { decryptSymmetric, deriveArgonKey, encryptAssymmetric, - encryptSymmetric -}; + encryptSymmetric, + generateKeyPair}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index c2035bbba..1ae7a4e05 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -2,6 +2,7 @@ export * from './auth'; export * from './incidentContacts'; export * from './keys'; export * from './organization'; +export * from './serviceAccounts'; export * from './serviceTokens'; export * from './subscriptions'; export * from './tags'; diff --git a/frontend/src/hooks/api/serviceAccounts/index.tsx b/frontend/src/hooks/api/serviceAccounts/index.tsx new file mode 100644 index 000000000..f9176b69a --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/index.tsx @@ -0,0 +1,9 @@ +export { + useCreateServiceAccount, + useCreateServiceAccountProjectLevelPermissions, + useDeleteServiceAccount, + useDeleteServiceAccountProjectLevelPermissions, + useGetServiceAccountById, + useGetServiceAccountProjectLevelPermissions, + useGetServiceAccounts, + 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 new file mode 100644 index 000000000..62dab2895 --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/queries.tsx @@ -0,0 +1,146 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiRequest } from '@app/config/request'; + +import { + CreateServiceAccountDTO, + CreateServiceAccountRes, + CreateServiceAccountWorkspacePermissionsDTO, + DeleteServiceAccountRes, + DeleteServiceAccountWorkspacePermissionsDTO, + DeleteServiceAccountWorkspacePermissionsRes, + RenameServiceAccountDTO, + RenameServiceAccountRes, + ServiceAccount, + ServiceAccountWorkspacePermissions} from './types'; + +const serviceAccountKeys = { + getServiceAccountById: (serviceAccountId: string) => [{ serviceAccountId }, 'service-account'] as const, + getServiceAccounts: (organizationID: string) => [{ organizationID }, 'service-accounts'] as const, + getServiceAccountProjectLevelPermissions: (serviceAccountId: string) => [{ serviceAccountId }, 'service-account-project-level-permissions'] as const +} + +const fetchServiceAccounts = async (organizationID: string) => { + const { data } = await apiRequest.get<{ serviceAccounts: ServiceAccount[] }>( + `/api/v2/organizations/${organizationID}/service-accounts` + ); + + return data.serviceAccounts; +} + +const fetchServiceAccountById = async (serviceAccountId: string) => { + const { data } = await apiRequest.get<{ serviceAccount: ServiceAccount }>( + `/api/v2/service-accounts/${serviceAccountId}` + ); + + return data.serviceAccount; +} + +export const useGetServiceAccounts = (organizationID: string) => + useQuery({ + queryKey: serviceAccountKeys.getServiceAccounts(organizationID), + queryFn: () => fetchServiceAccounts(organizationID), + enabled: Boolean(organizationID) + }); + +export const useGetServiceAccountById = (serviceAccountId: string) => { + return useQuery({ + queryKey: serviceAccountKeys.getServiceAccountById(serviceAccountId), + queryFn: () => fetchServiceAccountById(serviceAccountId), + enabled: true + }); +} + +export const useCreateServiceAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post('/api/v2/service-accounts/', body); + return data; + }, + onSuccess: ({ serviceAccount }) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(serviceAccount.organization)); + } + }); +} + +export const useRenameServiceAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ serviceAccountId, name }) => { + const { data: { serviceAccount } } = await apiRequest.patch(`/api/v2/service-accounts/${serviceAccountId}/name`, { name }); + return serviceAccount; + }, + onSuccess: (serviceAccount) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountById(serviceAccount._id)); + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(serviceAccount.organization)); + } + }); +} + +export const useDeleteServiceAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (serviceAccountId) => { + const { data: { serviceAccount } } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}`); + return serviceAccount; + }, + onSuccess: ({ organization }) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(organization)); + } + }); +} + +const fetchServiceAccountProjectLevelPermissions = async (serviceAccountId: string) => { + const { data: { permissions } } = await apiRequest.get<{ permissions: ServiceAccountWorkspacePermissions[] }>( + `/api/v2/service-accounts/${serviceAccountId}/permissions/workspace` + ); + + console.log('fetchServiceAccountProjectLevelPermissions'); + console.log('prrr: ', permissions); + + return permissions; +} + +export const useGetServiceAccountProjectLevelPermissions = (serviceAccountId: string) => { + return useQuery({ + queryKey: serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccountId), + queryFn: () => fetchServiceAccountProjectLevelPermissions(serviceAccountId), + enabled: true + }); +} + +export const useCreateServiceAccountProjectLevelPermissions = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body) => { + const { data: { permissions } } = await apiRequest.post(`/api/v2/service-accounts/${body.serviceAccountId}/permissions/workspace`, body); + return permissions; + }, + onSuccess: ({ serviceAccount }) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount)); + } + }); +} + +export const useDeleteServiceAccountProjectLevelPermissions = () => { + 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; + }, + onSuccess: ({ serviceAccount }) => { + console.log('onSuccess3: ', serviceAccount); + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount)); + // queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(organization)); + } + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceAccounts/types.ts b/frontend/src/hooks/api/serviceAccounts/types.ts new file mode 100644 index 000000000..2b5fc5b81 --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/types.ts @@ -0,0 +1,66 @@ +export type ServiceAccount = { + _id: string; + name: string; + organization: string; + user: string; + publicKey: string; + expiresAt: string; +} + +export type CreateServiceAccountDTO = { + name: string; + organizationId: string; + publicKey: string; + expiresIn: number; +} + +export type CreateServiceAccountRes = { + serviceAccount: ServiceAccount; + serviceAccountAccessKey: string; +} + +export type DeleteServiceAccountRes = { + serviceAccount: ServiceAccount; +} + +export type RenameServiceAccountDTO = { + serviceAccountId: string; + name: string; +} + +export type RenameServiceAccountRes = { + serviceAccount: ServiceAccount; +} + +export type ServiceAccountWorkspacePermissions = { + serviceAccount: string; + workspace: string; + environment: string; + canRead: boolean; + canWrite: boolean; + canUpdate: boolean; + canDelete: boolean; +} + +export type CreateServiceAccountWorkspacePermissionsDTO = { + serviceAccountId: string; + workspaceId: string; + environment: string; + canRead: boolean; + canWrite: boolean; + canUpdate: boolean; + canDelete: boolean; +} + +export type CreateServiceAccountWorkspacePermissionsRes = { + permissions: ServiceAccountWorkspacePermissions +} + +export type DeleteServiceAccountWorkspacePermissionsDTO = { + serviceAccountId: string; + serviceAccountWorkspacePermissionsId: string; +} + +export type DeleteServiceAccountWorkspacePermissionsRes = { + permissions: ServiceAccountWorkspacePermissions +} \ No newline at end of file diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 46b339287..b539bb845 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -93,8 +93,8 @@ export const AppLayout = ({ children }: LayoutProps) => { // Placing the localstorage as much as possible // Wait till tony integrates the azure and its launched useEffect(() => { - // Put a user in a workspace if they're not in one yet + const putUserInWorkSpace = async () => { if (tempLocalStorage('orgData.id') === '') { const userOrgs = await getOrganizations(); @@ -114,9 +114,33 @@ export const AppLayout = ({ children }: LayoutProps) => { ) { router.push('/noprojects'); } else if (router.asPath !== '/noprojects') { - const intendedWorkspaceId = router.asPath - .split('/') - [router.asPath.split('/').length - 1].split('?')[0]; + + // const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0); + + // let intendedWorkspaceId; + // if (pathSegments.length >= 2 && pathSegments[0] === 'dashboard') { + // intendedWorkspaceId = pathSegments[1]; + // } else if (pathSegments.length >= 3 && pathSegments[0] === 'settings') { + // intendedWorkspaceId = pathSegments[2]; + // } else { + // intendedWorkspaceId = router.asPath + // .split('/') + // [router.asPath.split('/').length - 1].split('?')[0]; + // } + + const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0); + + let intendedWorkspaceId; + if (pathSegments.length >= 2 && pathSegments[0] === 'dashboard') { + [, intendedWorkspaceId] = pathSegments; + } else if (pathSegments.length >= 3 && pathSegments[0] === 'settings') { + [, , intendedWorkspaceId] = pathSegments; + } else { + const lastPathSegment = router.asPath.split('/').pop().split('?'); + [intendedWorkspaceId] = lastPathSegment; + } + + if (!intendedWorkspaceId) return; if (!['callback', 'create', 'authorize'].includes(intendedWorkspaceId)) { localStorage.setItem('projectData.id', intendedWorkspaceId); @@ -192,7 +216,6 @@ export const AppLayout = ({ children }: LayoutProps) => { }); if (addMembers) { - console.log('adding other users'); // not using hooks because need at this point only const orgUsers = await fetchOrgUsers(currentOrg._id); orgUsers.forEach(({ status, user: orgUser }) => { diff --git a/frontend/src/pages/settings/org/[id].tsx b/frontend/src/pages/settings/org/[id]/index.tsx similarity index 100% rename from frontend/src/pages/settings/org/[id].tsx rename to frontend/src/pages/settings/org/[id]/index.tsx diff --git a/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx b/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx new file mode 100644 index 000000000..332fbbf27 --- /dev/null +++ b/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx @@ -0,0 +1,33 @@ +/* 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 + + +
+ + + ); +} + +ServiceAccountPage.requireAuth = true; + +export const getServerSidePros = getTranslatedServerSideProps([ + 'settings', + 'settings-org', + 'section-incident', + 'section-members' +]); \ No newline at end of file diff --git a/frontend/src/pages/settings/service-account/[id].tsx b/frontend/src/pages/settings/service-account/[id].tsx new file mode 100644 index 000000000..1f23f980d --- /dev/null +++ b/frontend/src/pages/settings/service-account/[id].tsx @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import Head from 'next/head'; + +export default function NewServiceAccountPage() { + console.log('NewServiceAccountPage'); + return ( +
+ + Some title + + +
+ Hello! +
+ {/* */} +
+ ); +} + +// NewServiceAccountPage.requireAuth = true; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx new file mode 100644 index 000000000..d1c91ff35 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx @@ -0,0 +1,43 @@ +import { useRouter } from 'next/router'; + +import NavHeader from '@app/components/navigation/NavHeader'; + +import { SAProjectLevelPermissionsTable } from './components/SAProjectLevelPermissionsTable'; +import { ServiceAccountNameChangeSection } from './components'; + +export const CreateServiceAccountPage = () => { + const router = useRouter(); + const { serviceAccountId }: { serviceAccountId: string } = router.query; + + return ( +
+ +
+

Service Account

+

+ A service account represents a machine identity such as a VM or application client. +

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

Organization-Level Permissions

+ +
*/} +
+

Project-Level Permissions

+ +
+
+
+ ); +} \ 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 new file mode 100644 index 000000000..e64f54b89 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx @@ -0,0 +1,394 @@ +import { useEffect, useState } from 'react'; +import { Controller,useForm } from 'react-hook-form'; +import { + faKey, + faMagnifyingGlass, + faPlus, + faTrash} 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, + Checkbox, + DeleteActionModal, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr} from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { + useCreateServiceAccountProjectLevelPermissions, + useDeleteServiceAccountProjectLevelPermissions, + useGetServiceAccountProjectLevelPermissions, + useGetUserWorkspaces} from '@app/hooks/api'; + +const createProjectLevelPermissionSchema = yup.object({ + workspace: yup.string().required().label('Workspace'), + environment: yup.string().required().label('Environment'), + permissions: yup.object().shape({ + canRead: yup.boolean().required(), + canWrite: yup.boolean().required(), + canUpdate: yup.boolean().required(), + canDelete: yup.boolean().required(), + }).defined().required() +}); + +type CreateProjectLevelPermissionForm = yup.InferType; + +type Props = { + serviceAccountId: string; +} + +export const SAProjectLevelPermissionsTable = ({ + serviceAccountId +}: Props) => { + const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces(); + const [searchPermissions, setSearchPermissions] = useState(''); + const [defaultValues, setDefaultValues] = useState(undefined); + + const { data: permissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId); + + const createServiceAccountProjectLevelPermissions = useCreateServiceAccountProjectLevelPermissions(); + const deleteServiceAccountProjectLevelPermissions = useDeleteServiceAccountProjectLevelPermissions(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addProjectLevelPermissions', + 'removeProjectLevelPermissions', + ] as const); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(createProjectLevelPermissionSchema), defaultValues }) + + const onAddProjectLevelPermissions = async ({ + workspace, + environment, + permissions: { canRead, canWrite, canUpdate, canDelete } + }: CreateProjectLevelPermissionForm) => { + await createServiceAccountProjectLevelPermissions.mutateAsync({ + serviceAccountId, + workspaceId: workspace, + environment, + canRead, + canWrite, + canUpdate, + canDelete + }); + handlePopUpClose('addProjectLevelPermissions'); + } + + const onRemoveProjectLevelPermissions = async () => { + const serviceAccountWorkspacePermissionsId = (popUp?.removeProjectLevelPermissions?.data as { _id: string })?._id; + await deleteServiceAccountProjectLevelPermissions.mutateAsync({ + serviceAccountId, + serviceAccountWorkspacePermissionsId + }); + handlePopUpClose('removeProjectLevelPermissions'); + } + + useEffect(() => { + if (userWorkspaces) { + setDefaultValues({ + workspace: String(userWorkspaces?.[0]?._id), + environment: String(userWorkspaces?.[0]?.environments?.[0]?.slug), + permissions: { + canRead: true, + canWrite: false, + canUpdate: false, + canDelete: false, + } + }); + } + }, [userWorkspaces]); + + + return ( +
+
+
+ setSearchPermissions(e.target.value)} + leftIcon={} + placeholder="Search service account project-level permissions..." + /> +
+ +
+ + + + + + + + + + + + + + {isPermissionsLoading && } + {!isPermissionsLoading && permissions && ( + permissions.map(({ + _id, + workspace, + environment, + canRead, + canWrite, + canUpdate, + canDelete + }) => { + const environmentName = (workspace.environments.find((env) => env.slug === environment))?.name; + return ( + + + + + + + + + + ); + }) + )} + {!isPermissionsLoading && permissions?.length === 0 && ( + + + + )} + +
ProjectEnvironmentReadWriteUpdateDelete +
{workspace.name}{environmentName} + + + + + + + + + handlePopUpOpen('removeProjectLevelPermissions', { _id })} + > + + +
+ +
+
+ { + handlePopUpToggle('addProjectLevelPermissions', isOpen); + }} + > + +
+ {!isUserWorkspacesLoading && userWorkspaces && ( + <> + ( + + + + )} + /> + { + /* eslint-disable-next-line no-underscore-dangle */ + const environments = userWorkspaces?.find((userWorkspace) => userWorkspace._id === control?._formValues?.workspace)?.environments ?? []; + return ( + + + + ); + }} + /> + + )} + { + const options = [ + { + label: 'Read (default)', + value: 'canRead' + }, + { + label: 'Write', + value: 'canWrite' + }, + { + label: 'Update', + value: 'canUpdate' + }, + { + label: 'Delete', + value: 'canDelete' + } + ]; + + return ( + + <> + {options.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} + + + ); + }} + /> +
+ + + + +
+ +
+
+ handlePopUpToggle('removeProjectLevelPermissions', isOpen)} + onDeleteApproved={onRemoveProjectLevelPermissions} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx new file mode 100644 index 000000000..164b60d51 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx @@ -0,0 +1 @@ +export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable'; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx new file mode 100644 index 000000000..006975544 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx @@ -0,0 +1,88 @@ +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, + Input} from '@app/components/v2'; +import { + useGetServiceAccountById, + useRenameServiceAccount +} from '@app/hooks/api'; + +const formSchema = yup.object({ + name: yup.string().required().label('Service Account Name') +}); + +type FormData = yup.InferType; + +type Props = { + serviceAccountId: string; +} + +export const ServiceAccountNameChangeSection = ({ + serviceAccountId +}: Props) => { + const { data: serviceAccount, isLoading: isServiceAccountLoading } = useGetServiceAccountById(serviceAccountId); + + const renameServiceAccount = useRenameServiceAccount(); + + const { + handleSubmit, + control, + reset, + formState: { isDirty, isSubmitting } + } = useForm({ resolver: yupResolver(formSchema) }); + + useEffect(() => { + reset({ name: serviceAccount?.name }); + }, [serviceAccount?.name]); + + const onFormSubmit = async ({ name }: FormData) => { + try { + await renameServiceAccount.mutateAsync({ + serviceAccountId, + name + }); + } catch (err) { + console.error(err); + } + } + + return ( +
+

Service Account Name

+
+ {!isServiceAccountLoading && ( + ( + + + + )} + control={control} + name="name" + /> + )} +
+ +
+ ); +} diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx new file mode 100644 index 000000000..711dae779 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx @@ -0,0 +1 @@ +export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx new file mode 100644 index 000000000..151430ddf --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx @@ -0,0 +1,2 @@ +export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable'; +export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx new file mode 100644 index 000000000..b308a6841 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx @@ -0,0 +1 @@ +export { CreateServiceAccountPage } from './CreateServiceAccountPage'; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx index 473b6f8c3..e74e1d5d4 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -20,10 +20,15 @@ import { useGetUserWsKey, useRenameOrg, useUpdateOrgUserRole, - useUploadWsKey + useUploadWsKey, } from '@app/hooks/api'; -import { OrgIncidentContactsTable, OrgMembersTable, OrgNameChangeSection } from './components'; +import { + OrgIncidentContactsTable, + OrgMembersTable, + OrgNameChangeSection, + OrgServiceAccountsTable +} from './components'; export const OrgSettingsPage = () => { const host = window.location.origin; @@ -36,12 +41,11 @@ export const OrgSettingsPage = () => { const { createNotification } = useNotificationContext(); const orgId = currentOrg?._id || ''; + const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId); - const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = - useGetUserWorkspaceMemberships(orgId); + const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = useGetUserWorkspaceMemberships(orgId); const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || ''); - const { data: incidentContact, isLoading: IsIncidentContactLoading } = - useGetOrgIncidentContact(orgId); + const { data: incidentContact, isLoading: IsIncidentContactLoading } = useGetOrgIncidentContact(orgId); const renameOrg = useRenameOrg(); const removeUserOrgMembership = useDeleteOrgMembership(); @@ -222,17 +226,15 @@ export const OrgSettingsPage = () => { return (
-
-
-

{t('settings-org:title')}

-

- {t('settings-org:description')} -

-
+
+

{t('settings-org:title')}

+

+ {t('settings-org:description')} +

-
+

{t('section-members:org-members')}

@@ -249,6 +251,12 @@ export const OrgSettingsPage = () => { onGrantAccess={onGrantUserAccess} />
+
+

+ Service Accounts +

+ +
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx index c3bd55f51..51829e1df 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx @@ -26,7 +26,8 @@ import { Td, Th, THead, - Tr} from '@app/components/v2'; + Tr +} from '@app/components/v2'; import { usePopUp } from '@app/hooks'; import { IncidentContact } from '@app/hooks/api/types'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx index 9bed5954b..2fcba82b4 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -93,7 +93,7 @@ export const OrgMembersTable = ({ () => members.find(({ user }) => userId === user?._id)?.role === 'owner', [userId, members] ); - + const filterdUser = useMemo( () => members.filter( @@ -117,20 +117,18 @@ export const OrgMembersTable = ({ placeholder="Search members..." />
-
- -
+
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx new file mode 100644 index 000000000..3bab7f4bc --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx @@ -0,0 +1,353 @@ +import { useEffect, useMemo,useState } from 'react'; +import { Controller,useForm } from 'react-hook-form'; +import { useRouter } from 'next/router'; +import { + faCheck, + faCopy, + faMagnifyingGlass, + faPencil, + faPlus, + faServer, + faTrash} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { generateKeyPair } from '@app/components/utilities/cryptography/crypto'; +import { + Button, + DeleteActionModal, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from '@app/components/v2'; +import { useOrganization, useWorkspace } from '@app/context'; +import { usePopUp, useToggle } from '@app/hooks'; +import { + useCreateServiceAccount, + useDeleteServiceAccount, + useGetServiceAccounts} from '@app/hooks/api'; + +const serviceAccountExpiration = [ + { label: '1 Day', value: 86400 }, + { label: '7 Days', value: 604800 }, + { label: '1 Month', value: 2592000 }, + { label: '6 months', value: 15552000 }, + { label: '12 months', value: 31104000 }, + { label: 'Never', value: -1 } +]; + +const addServiceAccountFormSchema = yup.object({ + name: yup.string().required().label('Name').trim(), + expiresIn: yup.string().required().label('Service Account Expiration') +}); + +type TAddServiceAccountForm = yup.InferType; + +export const OrgServiceAccountsTable = () => { + const router = useRouter(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + console.log('currentWorkspace: ', currentWorkspace); + + const orgId = currentOrg?._id || ''; + const [step, setStep] = useState(0); + const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false); + const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false); + const [accessKey, setAccessKey] = useState(''); + const [privateKey, setPrivateKey] = useState(''); + const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState(''); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addServiceAccount', + 'removeServiceAccount', + ] as const); + + const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId); + + const createServiceAccount = useCreateServiceAccount(); + const removeServiceAccount = useDeleteServiceAccount(); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isAccessKeyCopied) { + timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); + } + + if (isPrivateKeyCopied) { + timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isAccessKeyCopied, isPrivateKeyCopied]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(addServiceAccountFormSchema) }); + + const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => { + if (!currentOrg?._id) return; + + const keyPair = generateKeyPair(); + setPrivateKey(keyPair.privateKey); + + const serviceAccountDetails = await createServiceAccount.mutateAsync({ + name, + organizationId: currentOrg?._id, + publicKey: keyPair.publicKey, + expiresIn + }); + + console.log('serviceAccountDetails: ', serviceAccountDetails); + + setAccessKey(serviceAccountDetails.serviceAccountAccessKey); + + setStep(1); + reset(); + } + + 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'); + } + + const filteredServiceAccounts = useMemo( + () => + serviceAccounts.filter( + ({ name }) => + name.toLowerCase().includes(searchServiceAccountFilter) + ), + [serviceAccounts, searchServiceAccountFilter] + ); + + const renderStep = (stepToRender: number) => { + switch (stepToRender) { + case 0: + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ); + case 1: + return ( + <> +

Access Key

+
+

{accessKey}

+ { + navigator.clipboard.writeText(accessKey); + setIsAccessKeyCopied.on(); + }} + > + + + Copy + + +
+

Private Key

+
+

{privateKey}

+ { + navigator.clipboard.writeText(privateKey); + setIsPrivateKeyCopied.on(); + }} + > + + + Copy + + +
+ + ); + default: + return
+ } + } + + console.log('serviceAccounts: ', serviceAccounts); + + return ( +
+
+
+ setSearchServiceAccountFilter(e.target.value)} + leftIcon={} + placeholder="Search service accounts..." + /> +
+ +
+ + + + + + + + {isServiceAccountsLoading && } + {!isServiceAccountsLoading && ( + filteredServiceAccounts.map(({ + name, + expiresAt, + _id: serviceAccountId + }) => { + return ( + + + + + + ); + }) + )} + {!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && ( + + + + )} + +
NameValid Until +
{name}{new Date(expiresAt).toUTCString()} +
+ { + if (currentWorkspace?._id) { + router.push(`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`); + } + }} + className="mr-2" + > + + + handlePopUpOpen('removeServiceAccount', { _id: serviceAccountId })} + > + + +
+
+ +
+
+ { + handlePopUpToggle('addServiceAccount', isOpen); + reset(); + }} + > + + {renderStep(step)} + + + handlePopUpToggle('removeServiceAccount', isOpen)} + onDeleteApproved={onRemoveServiceAccount} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx new file mode 100644 index 000000000..fb22cd2c4 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx @@ -0,0 +1 @@ +export { OrgServiceAccountsTable } from './OrgServiceAccountsTable'; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx index 15a09b0d2..bfe044235 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx @@ -1,3 +1,5 @@ export { OrgIncidentContactsTable } from './OrgIncidentContactsTable'; export { OrgMembersTable } from './OrgMembersTable'; export { OrgNameChangeSection } from './OrgNameChangeSection'; +export { OrgServiceAccountsTable } from './OrgServiceAccountsTable'; + diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx index 429876435..b857a3224 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -43,7 +43,7 @@ const apiTokenExpiry = [ const createServiceTokenSchema = yup.object({ name: yup.string().required().label('Service Token Name'), environment: yup.string().required().label('Environment'), - expiresIn: yup.string().required().label('Service Token Name'), + expiresIn: yup.string().required().label('Service Token Expiration'), permissions: yup.object().shape({ read: yup.boolean().required(), write: yup.boolean().required() @@ -202,7 +202,7 @@ export const ServiceTokenSection = ({ defaultValue={String(apiTokenExpiry?.[0]?.value)} render={({ field: { onChange, ...field }, fieldState: { error } }) => ( @@ -269,42 +269,6 @@ export const ServiceTokenSection = ({ ); }} /> - {/* { - return ( - { - onChange(state); - }} - > - Read (default) - - ); - }} - /> - { - return ( - { - onChange(state); - }} - > - Write (optional) - - ); - }} - /> */}
+ +
+ ); +} + +RailwayAuthorizeIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/integrations/railway/create.tsx b/frontend/src/pages/integrations/railway/create.tsx new file mode 100644 index 000000000..11e948628 --- /dev/null +++ b/frontend/src/pages/integrations/railway/create.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { + Button, + Card, + CardTitle, + FormControl, + Select, + SelectItem +} from '../../../components/v2'; +import { useGetIntegrationAuthApps, useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; + +export default function RailwayCreateIntegrationPage() { + const router = useRouter(); + + const [targetAppId, setTargetAppId] = useState(''); + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const { integrationAuthId } = queryString.parse(router.asPath.split('?')[1]); + const { data: integrationAuth } = useGetIntegrationAuthById(integrationAuthId as string ?? ''); + const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: integrationAuthId as string ?? '' + }); + + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetAppId(integrationAuthApps[0].appId as string); + } else { + setTargetAppId('none'); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + setIsLoading(true); + + if (!integrationAuth?._id) return; + + // const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId))?.name; + + console.log('handleButtonClick'); + + setIsLoading(false); + } catch (err) { + console.error(err); + } + } + + console.log('integrationAuthApps', integrationAuthApps); + + return workspace && selectedSourceEnvironment && integrationAuthApps ? ( +
+ + Railway Integration + + + + + + + + +
+ ) :
+} + +RailwayCreateIntegrationPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); + diff --git a/frontend/src/pages/integrations/render/create.tsx b/frontend/src/pages/integrations/render/create.tsx index cc61eec3e..e5ee04e0a 100644 --- a/frontend/src/pages/integrations/render/create.tsx +++ b/frontend/src/pages/integrations/render/create.tsx @@ -11,7 +11,7 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthApps,useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { useGetIntegrationAuthApps, useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; From cbf05b7c3122ea254d310c87f7cd3adffaa9dd95 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 9 Apr 2023 01:31:40 +0300 Subject: [PATCH 14/16] Finish first iteration of Railway integration --- .../v1/integrationAuthController.ts | 73 +++++++++++- .../controllers/v1/integrationController.ts | 2 + backend/src/integrations/sync.ts | 59 ++++++++++ backend/src/models/integration.ts | 5 + backend/src/routes/v1/integration.ts | 1 + backend/src/routes/v1/integrationAuth.ts | 14 +++ .../src/hooks/api/integrationAuth/index.tsx | 3 +- .../src/hooks/api/integrationAuth/queries.tsx | 46 +++++++- .../src/hooks/api/integrationAuth/types.ts | 5 + .../api/integrations/createIntegration.ts | 3 + frontend/src/pages/integrations/[id].tsx | 3 +- .../aws-parameter-store/create.tsx | 1 + .../aws-secret-manager/create.tsx | 1 + .../integrations/azure-key-vault/create.tsx | 1 + .../pages/integrations/circleci/create.tsx | 1 + .../src/pages/integrations/flyio/create.tsx | 1 + .../src/pages/integrations/github/create.tsx | 1 + .../src/pages/integrations/gitlab/create.tsx | 1 + .../src/pages/integrations/heroku/create.tsx | 1 + .../src/pages/integrations/netlify/create.tsx | 1 + .../src/pages/integrations/railway/create.tsx | 105 +++++++++++++----- .../src/pages/integrations/render/create.tsx | 2 +- .../pages/integrations/travisci/create.tsx | 1 + .../src/pages/integrations/vercel/create.tsx | 1 + 24 files changed, 300 insertions(+), 32 deletions(-) diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 3f28787de..edf9b8333 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -13,7 +13,8 @@ import { revokeAccess } from '../../integrations'; import { - INTEGRATION_VERCEL_API_URL + INTEGRATION_VERCEL_API_URL, + INTEGRATION_RAILWAY_API_URL } from '../../variables'; import request from '../../config/request'; @@ -203,7 +204,8 @@ export const getIntegrationAuthTeams = async (req: Request, res: Response) => { } /** - * Return list of available Vercel (preview) branches + * Return list of available Vercel (preview) branches for Vercel project with + * id [appId] * @param req * @param res */ @@ -246,6 +248,73 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon }); } +/** + * Return list of available Railway environments for Railway project with + * id [appId] + * @param req + * @param res + */ +export const getIntegrationAuthRailwayEnvironments = async (req: Request, res: Response) => { + const { integrationAuthId } = req.params; + const appId = req.query.appId as string; + + interface RailwayEnvironment { + node: { + id: string; + name: string; + isEphemeral: boolean; + } + } + + interface Environment { + environmentId: string; + name: string; + } + + let environments: Environment[] = []; + + if (appId && appId !== '') { + const query = ` + query GetEnvironments($projectId: String!, $after: String, $before: String, $first: Int, $isEphemeral: Boolean, $last: Int) { + environments(projectId: $projectId, after: $after, before: $before, first: $first, isEphemeral: $isEphemeral, last: $last) { + edges { + node { + id + name + isEphemeral + } + } + } + } + `; + + const variables = { + projectId: appId + } + + const { data: { data: { environments: { edges } } } } = await request.post(INTEGRATION_RAILWAY_API_URL, { + query, + variables, + }, { + headers: { + 'Authorization': `Bearer ${req.accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + environments = edges.map((e: RailwayEnvironment) => { + return ({ + name: e.node.name, + environmentId: e.node.id + }); + }); + } + + return res.status(200).send({ + environments + }); +} + /** * Delete integration authorization with id [integrationAuthId] * @param req diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 827ce21b9..b4c627925 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -24,6 +24,7 @@ export const createIntegration = async (req: Request, res: Response) => { isActive, sourceEnvironment, targetEnvironment, + targetEnvironmentId, owner, path, region @@ -39,6 +40,7 @@ export const createIntegration = async (req: Request, res: Response) => { app, appId, targetEnvironment, + targetEnvironmentId, owner, path, region, diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index ac2b27043..99b0825c1 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -21,6 +21,7 @@ import { INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, @@ -29,11 +30,13 @@ import { INTEGRATION_VERCEL_API_URL, INTEGRATION_NETLIFY_API_URL, INTEGRATION_RENDER_API_URL, + INTEGRATION_RAILWAY_API_URL, INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, } from "../variables"; import request from '../config/request'; +import axios from "axios"; /** * Sync/push [secrets] to [app] in integration named [integration] @@ -126,6 +129,13 @@ const syncSecrets = async ({ accessToken, }); break; + case INTEGRATION_RAILWAY: + await syncSecretsRailway({ + integration, + secrets, + accessToken + }); + break; case INTEGRATION_FLYIO: await syncSecretsFlyio({ integration, @@ -1152,6 +1162,55 @@ const syncSecretsRender = async ({ } }; +/** + * Sync/push [secrets] to Railway project with id [integration.appId] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Railway integration + */ +const syncSecretsRailway = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + const query = ` + mutation UpsertVariables($input: VariableCollectionUpsertInput!) { + variableCollectionUpsert(input: $input) + } + `; + + const input = { + projectId: integration.appId, + environmentId: integration.targetEnvironmentId, + replace: true, + variables: secrets + }; + + await request.post(INTEGRATION_RAILWAY_API_URL, { + query, + variables: { + input, + }, + }, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to sync secrets to Railway"); + } +} + /** * Sync/push [secrets] to Fly.io app * @param {Object} obj diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 4402bc9b5..13a095609 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -23,6 +23,7 @@ export interface IIntegration { app: string; owner: string; targetEnvironment: string; + targetEnvironmentId: string; appId: string; path: string; region: string; @@ -73,6 +74,10 @@ const integrationSchema = new Schema( type: String, default: null, }, + targetEnvironmentId: { + type: String, + default: null + }, owner: { // github-specific repo owner-login type: String, diff --git a/backend/src/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index 42b76d88d..8a862d85a 100644 --- a/backend/src/routes/v1/integration.ts +++ b/backend/src/routes/v1/integration.ts @@ -30,6 +30,7 @@ router.post( // new: add new integration for integration auth body('appId').trim(), body('sourceEnvironment').trim(), body('targetEnvironment').trim(), + body('targetEnvironmentId').trim(), body('owner').trim(), body('path').trim(), body('region').trim(), diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index 9d0fd3aad..e8c9e7ad5 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -111,6 +111,20 @@ router.get( integrationAuthController.getIntegrationAuthVercelBranches ); +router.get( + '/:integrationAuthId/railway/environments', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireIntegrationAuthorizationAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + param('integrationAuthId').exists().isString(), + query('appId').exists().isString(), + validateRequest, + integrationAuthController.getIntegrationAuthRailwayEnvironments +); + router.delete( '/:integrationAuthId', requireAuth({ diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index 443316c19..cabf26f9a 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -2,5 +2,6 @@ export { useGetIntegrationAuthApps, useGetIntegrationAuthById, useGetIntegrationAuthTeams, - useGetIntegrationAuthVercelBranches + useGetIntegrationAuthVercelBranches, + useGetRailwayEnvironments } from './queries'; \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 167f39a7d..50a0618f6 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -4,6 +4,7 @@ import { apiRequest } from "@app/config/request"; import { App, + Environment, IntegrationAuth, Team } from './types'; @@ -18,7 +19,14 @@ const integrationAuthKeys = { }: { integrationAuthId: string; appId: string; - }) => [{ integrationAuthId, appId }, 'integrationAuthVercelBranches'] + }) => [{ integrationAuthId, appId }, 'integrationAuthVercelBranches'] as const, + getIntegrationAuthRailwayEnvironments: ({ + integrationAuthId, + appId + }: { + integrationAuthId: string; + appId: string; + }) => [{ integrationAuthId, appId }, 'integrationAuthRailwayEnvironments'] as const, } const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -62,6 +70,22 @@ const fetchIntegrationAuthVercelBranches = async ({ return branches; }; +const fetchIntegrationAuthRailwayEnvironments = async ({ + integrationAuthId, + appId +}: { + integrationAuthId: string; + appId: string; +}) => { + const { data: { environments } } = await apiRequest.get<{ environments: Environment[] }>(`/api/v1/integration-auth/${integrationAuthId}/railway/environments`, { + params: { + appId + } + }); + + return environments; +} + export const useGetIntegrationAuthById = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), @@ -114,3 +138,23 @@ export const useGetIntegrationAuthVercelBranches = ({ enabled: true }); } + +export const useGetRailwayEnvironments = ({ + integrationAuthId, + appId +}: { + integrationAuthId: string; + appId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthRailwayEnvironments({ + integrationAuthId, + appId, + }), + queryFn: () => fetchIntegrationAuthRailwayEnvironments({ + integrationAuthId, + appId, + }), + enabled: true + }); +} diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 4ba19c192..d13489757 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -15,4 +15,9 @@ export type App = { export type Team = { name: string; teamId: string; +} + +export type Environment = { + name: string; + environmentId: string; } \ No newline at end of file diff --git a/frontend/src/pages/api/integrations/createIntegration.ts b/frontend/src/pages/api/integrations/createIntegration.ts index d861223f1..393c40afd 100644 --- a/frontend/src/pages/api/integrations/createIntegration.ts +++ b/frontend/src/pages/api/integrations/createIntegration.ts @@ -7,6 +7,7 @@ interface Props { appId: string | null; sourceEnvironment: string; targetEnvironment: string | null; + targetEnvironmentId: string | null; owner: string | null; path: string | null; region: string | null; @@ -24,6 +25,7 @@ const createIntegration = ({ appId, sourceEnvironment, targetEnvironment, + targetEnvironmentId, owner, path, region @@ -40,6 +42,7 @@ const createIntegration = ({ appId, sourceEnvironment, targetEnvironment, + targetEnvironmentId, owner, path, region diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index f58d1580d..be9e3718f 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -208,7 +208,6 @@ export default function Integrations() { link = `${window.location.origin}/integrations/travisci/authorize`; break; case 'railway': - console.log('handleUnauthorized Railway: ', integrationOption); link = `${window.location.origin}/integrations/railway/authorize`; break; default: @@ -264,7 +263,7 @@ export default function Integrations() { link = `${window.location.origin}/integrations/travisci/create?integrationAuthId=${integrationAuth._id}`; break; case 'railway': - console.log('handleAuthorized Railway: ', integrationAuth); + link = `${window.location.origin}/integrations/railway/create?integrationAuthId=${integrationAuth._id}`; break; default: break; diff --git a/frontend/src/pages/integrations/aws-parameter-store/create.tsx b/frontend/src/pages/integrations/aws-parameter-store/create.tsx index 624f5937a..01112eeff 100644 --- a/frontend/src/pages/integrations/aws-parameter-store/create.tsx +++ b/frontend/src/pages/integrations/aws-parameter-store/create.tsx @@ -98,6 +98,7 @@ export default function AWSParameterStoreCreateIntegrationPage() { appId: null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, owner: null, path, region: selectedAWSRegion diff --git a/frontend/src/pages/integrations/aws-secret-manager/create.tsx b/frontend/src/pages/integrations/aws-secret-manager/create.tsx index 804cf0504..c464564d6 100644 --- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx @@ -97,6 +97,7 @@ export default function AWSSecretManagerCreateIntegrationPage() { appId: null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, owner: null, path: null, region: selectedAWSRegion diff --git a/frontend/src/pages/integrations/azure-key-vault/create.tsx b/frontend/src/pages/integrations/azure-key-vault/create.tsx index bfd544748..779ec1798 100644 --- a/frontend/src/pages/integrations/azure-key-vault/create.tsx +++ b/frontend/src/pages/integrations/azure-key-vault/create.tsx @@ -62,6 +62,7 @@ export default function AzureKeyVaultCreateIntegrationPage() { appId: null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/circleci/create.tsx b/frontend/src/pages/integrations/circleci/create.tsx index b753e7dd7..cf0a8db69 100644 --- a/frontend/src/pages/integrations/circleci/create.tsx +++ b/frontend/src/pages/integrations/circleci/create.tsx @@ -60,6 +60,7 @@ export default function CircleCICreateIntegrationPage() { appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, owner: null, path: null, region: null, diff --git a/frontend/src/pages/integrations/flyio/create.tsx b/frontend/src/pages/integrations/flyio/create.tsx index 05b37affd..4783c64a9 100644 --- a/frontend/src/pages/integrations/flyio/create.tsx +++ b/frontend/src/pages/integrations/flyio/create.tsx @@ -61,6 +61,7 @@ export default function FlyioCreateIntegrationPage() { appId: null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index c4368fe4f..7a9825138 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -64,6 +64,7 @@ export default function GitHubCreateIntegrationPage() { appId: null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, owner: targetApp.owner, path: null, region: null diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx index f4da8c9ff..6d2e3aada 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -89,6 +89,7 @@ export default function GitLabCreateIntegrationPage() { appId: targetAppId, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/heroku/create.tsx b/frontend/src/pages/integrations/heroku/create.tsx index cae8dfe44..591b56646 100644 --- a/frontend/src/pages/integrations/heroku/create.tsx +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -60,6 +60,7 @@ export default function HerokuCreateIntegrationPage() { appId: null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/netlify/create.tsx b/frontend/src/pages/integrations/netlify/create.tsx index fb33e404b..88ef46f21 100644 --- a/frontend/src/pages/integrations/netlify/create.tsx +++ b/frontend/src/pages/integrations/netlify/create.tsx @@ -69,6 +69,7 @@ export default function NetlifyCreateIntegrationPage() { appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment, + targetEnvironmentId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/railway/create.tsx b/frontend/src/pages/integrations/railway/create.tsx index 11e948628..2e09ea262 100644 --- a/frontend/src/pages/integrations/railway/create.tsx +++ b/frontend/src/pages/integrations/railway/create.tsx @@ -11,13 +11,19 @@ import { Select, SelectItem } from '../../../components/v2'; -import { useGetIntegrationAuthApps, useGetIntegrationAuthById } from '../../../hooks/api/integrationAuth'; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById, + useGetRailwayEnvironments +} from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from "../../api/integrations/createIntegration"; export default function RailwayCreateIntegrationPage() { const router = useRouter(); const [targetAppId, setTargetAppId] = useState(''); + const [targetEnvironmentId, setTargetEnvironmentId] = useState(''); const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -27,8 +33,12 @@ export default function RailwayCreateIntegrationPage() { const { data: integrationAuthApps } = useGetIntegrationAuthApps({ integrationAuthId: integrationAuthId as string ?? '' }); - - + + const { data: targetEnvironments } = useGetRailwayEnvironments({ + integrationAuthId: integrationAuthId as string ?? '', + appId: targetAppId + }); + useEffect(() => { if (workspace) { setSelectedSourceEnvironment(workspace.environments[0].slug); @@ -45,25 +55,51 @@ export default function RailwayCreateIntegrationPage() { } }, [integrationAuthApps]); + useEffect(() => { + if (targetEnvironments) { + if (targetEnvironments.length > 0) { + setTargetEnvironmentId(targetEnvironments[0].environmentId); + } else { + setTargetEnvironmentId('none'); + } + } + }, [targetEnvironments]); + const handleButtonClick = async () => { try { setIsLoading(true); if (!integrationAuth?._id) return; - // const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId))?.name; + const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId); + const targetEnvironment = targetEnvironments?.find((environment) => environment.environmentId === targetEnvironmentId); - console.log('handleButtonClick'); + if (!targetApp || !targetApp.appId || !targetEnvironment) return; + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp.name, + appId: targetApp.appId, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: targetEnvironment.name, + targetEnvironmentId: targetEnvironment.environmentId, + owner: null, + path: null, + region: null + }); setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); } catch (err) { console.error(err); } } - console.log('integrationAuthApps', integrationAuthApps); - - return workspace && selectedSourceEnvironment && integrationAuthApps ? ( + return workspace && selectedSourceEnvironment && integrationAuthApps && targetEnvironments ? (
Railway Integration @@ -84,24 +120,43 @@ export default function RailwayCreateIntegrationPage() { - setTargetAppId(val)} + className='w-full border border-mineshaft-500' + isDisabled={integrationAuthApps.length === 0} + > + {integrationAuthApps.length > 0 ? ( + integrationAuthApps.map((integrationAuthApp) => ( + + {integrationAuthApp.name} + + )) + ) : ( + + No projects found - )) - ) : ( - - No projects found - - )} - + )} + + + +
); + case 'railway': + return ( +
+
ENVIRONMENT
+ +
+ ); default: return
; } diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index cabf26f9a..367534305 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -1,7 +1,7 @@ export { useGetIntegrationAuthApps, useGetIntegrationAuthById, + useGetIntegrationAuthRailwayEnvironments, + useGetIntegrationAuthRailwayServices, useGetIntegrationAuthTeams, - useGetIntegrationAuthVercelBranches, - useGetRailwayEnvironments -} from './queries'; \ No newline at end of file + useGetIntegrationAuthVercelBranches} from './queries'; \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 50a0618f6..02a8b6c51 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -6,6 +6,7 @@ import { App, Environment, IntegrationAuth, + Service, Team } from './types'; @@ -27,6 +28,13 @@ const integrationAuthKeys = { integrationAuthId: string; appId: string; }) => [{ integrationAuthId, appId }, 'integrationAuthRailwayEnvironments'] as const, + getIntegrationAuthRailwayServices: ({ + integrationAuthId, + appId + }: { + integrationAuthId: string; + appId: string; + }) => [{ integrationAuthId, appId }, 'integrationAuthRailwayServices'] as const } const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -86,6 +94,22 @@ const fetchIntegrationAuthRailwayEnvironments = async ({ return environments; } +const fetchIntegrationAuthRailwayServices = async ({ + integrationAuthId, + appId +}: { + integrationAuthId: string; + appId: string; +}) => { + const { data: { services } } = await apiRequest.get<{ services: Service[] }>(`/api/v1/integration-auth/${integrationAuthId}/railway/services`, { + params: { + appId + } + }); + + return services; +} + export const useGetIntegrationAuthById = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), @@ -139,7 +163,7 @@ export const useGetIntegrationAuthVercelBranches = ({ }); } -export const useGetRailwayEnvironments = ({ +export const useGetIntegrationAuthRailwayEnvironments = ({ integrationAuthId, appId }: { @@ -158,3 +182,23 @@ export const useGetRailwayEnvironments = ({ enabled: true }); } + +export const useGetIntegrationAuthRailwayServices = ({ + integrationAuthId, + appId +}: { + integrationAuthId: string; + appId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthRailwayServices({ + integrationAuthId, + appId, + }), + queryFn: () => fetchIntegrationAuthRailwayServices({ + integrationAuthId, + appId, + }), + enabled: true + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index d13489757..7ea8799a5 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -20,4 +20,9 @@ export type Team = { export type Environment = { name: string; environmentId: string; +} + +export type Service = { + name: string; + serviceId: string; } \ No newline at end of file diff --git a/frontend/src/pages/api/integrations/createIntegration.ts b/frontend/src/pages/api/integrations/createIntegration.ts index 393c40afd..e3e7c010a 100644 --- a/frontend/src/pages/api/integrations/createIntegration.ts +++ b/frontend/src/pages/api/integrations/createIntegration.ts @@ -8,6 +8,8 @@ interface Props { sourceEnvironment: string; targetEnvironment: string | null; targetEnvironmentId: string | null; + targetService: string | null; + targetServiceId: string | null; owner: string | null; path: string | null; region: string | null; @@ -26,6 +28,8 @@ const createIntegration = ({ sourceEnvironment, targetEnvironment, targetEnvironmentId, + targetService, + targetServiceId, owner, path, region @@ -43,6 +47,8 @@ const createIntegration = ({ sourceEnvironment, targetEnvironment, targetEnvironmentId, + targetService, + targetServiceId, owner, path, region diff --git a/frontend/src/pages/integrations/aws-parameter-store/create.tsx b/frontend/src/pages/integrations/aws-parameter-store/create.tsx index 01112eeff..200628921 100644 --- a/frontend/src/pages/integrations/aws-parameter-store/create.tsx +++ b/frontend/src/pages/integrations/aws-parameter-store/create.tsx @@ -99,6 +99,8 @@ export default function AWSParameterStoreCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path, region: selectedAWSRegion diff --git a/frontend/src/pages/integrations/aws-secret-manager/create.tsx b/frontend/src/pages/integrations/aws-secret-manager/create.tsx index c464564d6..7f253e119 100644 --- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx @@ -98,6 +98,8 @@ export default function AWSSecretManagerCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: selectedAWSRegion diff --git a/frontend/src/pages/integrations/azure-key-vault/create.tsx b/frontend/src/pages/integrations/azure-key-vault/create.tsx index 779ec1798..8f506b6d4 100644 --- a/frontend/src/pages/integrations/azure-key-vault/create.tsx +++ b/frontend/src/pages/integrations/azure-key-vault/create.tsx @@ -63,6 +63,8 @@ export default function AzureKeyVaultCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/circleci/create.tsx b/frontend/src/pages/integrations/circleci/create.tsx index cf0a8db69..05a53e609 100644 --- a/frontend/src/pages/integrations/circleci/create.tsx +++ b/frontend/src/pages/integrations/circleci/create.tsx @@ -61,6 +61,8 @@ export default function CircleCICreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null, diff --git a/frontend/src/pages/integrations/flyio/create.tsx b/frontend/src/pages/integrations/flyio/create.tsx index 4783c64a9..13dbc05d1 100644 --- a/frontend/src/pages/integrations/flyio/create.tsx +++ b/frontend/src/pages/integrations/flyio/create.tsx @@ -62,6 +62,8 @@ export default function FlyioCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 7a9825138..eee9f8a71 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -65,6 +65,8 @@ export default function GitHubCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: targetApp.owner, path: null, region: null diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx index 6d2e3aada..c8a25e78b 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -90,6 +90,8 @@ export default function GitLabCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/heroku/create.tsx b/frontend/src/pages/integrations/heroku/create.tsx index 591b56646..beb52c4d4 100644 --- a/frontend/src/pages/integrations/heroku/create.tsx +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -61,6 +61,8 @@ export default function HerokuCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/netlify/create.tsx b/frontend/src/pages/integrations/netlify/create.tsx index 88ef46f21..cf3a23038 100644 --- a/frontend/src/pages/integrations/netlify/create.tsx +++ b/frontend/src/pages/integrations/netlify/create.tsx @@ -70,6 +70,8 @@ export default function NetlifyCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment, targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/railway/create.tsx b/frontend/src/pages/integrations/railway/create.tsx index 2e09ea262..95eb90c6f 100644 --- a/frontend/src/pages/integrations/railway/create.tsx +++ b/frontend/src/pages/integrations/railway/create.tsx @@ -14,7 +14,8 @@ import { import { useGetIntegrationAuthApps, useGetIntegrationAuthById, - useGetRailwayEnvironments + useGetIntegrationAuthRailwayEnvironments, + useGetIntegrationAuthRailwayServices } from '../../../hooks/api/integrationAuth'; import { useGetWorkspaceById } from '../../../hooks/api/workspace'; import createIntegration from "../../api/integrations/createIntegration"; @@ -24,6 +25,8 @@ export default function RailwayCreateIntegrationPage() { const [targetAppId, setTargetAppId] = useState(''); const [targetEnvironmentId, setTargetEnvironmentId] = useState(''); + const [targetServiceId, setTargetServiceId] = useState(''); + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -33,8 +36,11 @@ export default function RailwayCreateIntegrationPage() { const { data: integrationAuthApps } = useGetIntegrationAuthApps({ integrationAuthId: integrationAuthId as string ?? '' }); - - const { data: targetEnvironments } = useGetRailwayEnvironments({ + const { data: targetEnvironments } = useGetIntegrationAuthRailwayEnvironments({ + integrationAuthId: integrationAuthId as string ?? '', + appId: targetAppId + }); + const { data: targetServices } = useGetIntegrationAuthRailwayServices({ integrationAuthId: integrationAuthId as string ?? '', appId: targetAppId }); @@ -64,6 +70,12 @@ export default function RailwayCreateIntegrationPage() { } } }, [targetEnvironments]); + + const filteredServices = targetServices + ?.concat({ + name: '', + serviceId: '' + }); const handleButtonClick = async () => { try { @@ -75,6 +87,8 @@ export default function RailwayCreateIntegrationPage() { const targetEnvironment = targetEnvironments?.find((environment) => environment.environmentId === targetEnvironmentId); if (!targetApp || !targetApp.appId || !targetEnvironment) return; + + const targetService = targetServices?.find((service) => service.serviceId === targetServiceId); await createIntegration({ integrationAuthId: integrationAuth?._id, @@ -84,6 +98,8 @@ export default function RailwayCreateIntegrationPage() { sourceEnvironment: selectedSourceEnvironment, targetEnvironment: targetEnvironment.name, targetEnvironmentId: targetEnvironment.environmentId, + targetService: targetService ? targetService.name : null, + targetServiceId: targetService ? targetService.serviceId : null, owner: null, path: null, region: null @@ -99,7 +115,7 @@ export default function RailwayCreateIntegrationPage() { } } - return workspace && selectedSourceEnvironment && integrationAuthApps && targetEnvironments ? ( + return workspace && selectedSourceEnvironment && integrationAuthApps && targetEnvironments && filteredServices ? (
Railway Integration @@ -158,6 +174,19 @@ export default function RailwayCreateIntegrationPage() { )} + + +