diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 3f28787de..12a39a389 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,151 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon }); } +/** + * Return list of 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 + }); +} + +/** + * Return list of Railway services for Railway project with id + * [appId] + * @param req + * @param res + */ +export const getIntegrationAuthRailwayServices = async (req: Request, res: Response) => { + const { integrationAuthId } = req.params; + const appId = req.query.appId as string; + + interface RailwayService { + node: { + id: string; + name: string; + } + } + + interface Service { + name: string; + serviceId: string; + } + + let services: Service[] = []; + + const query = ` + query project($id: String!) { + project(id: $id) { + createdAt + deletedAt + id + description + expiredAt + isPublic + isTempProject + isUpdatable + name + prDeploys + teamId + updatedAt + upstreamUrl + services { + edges { + node { + id + name + } + } + } + } + } + `; + + if (appId && appId !== '') { + const variables = { + id: appId + } + + const { data: { data: { project: { services: { edges } } } } } = await request.post(INTEGRATION_RAILWAY_API_URL, { + query, + variables + }, { + headers: { + 'Authorization': `Bearer ${req.accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + services = edges.map((e: RailwayService) => ({ + name: e.node.name, + serviceId: e.node.id + })); + } + + return res.status(200).send({ + services + }); +} + /** * 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..6b633fc2c 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -24,6 +24,9 @@ export const createIntegration = async (req: Request, res: Response) => { isActive, sourceEnvironment, targetEnvironment, + targetEnvironmentId, + targetService, + targetServiceId, owner, path, region @@ -39,12 +42,15 @@ export const createIntegration = async (req: Request, res: Response) => { app, appId, targetEnvironment, + targetEnvironmentId, + targetService, + targetServiceId, owner, path, region, integration: req.integrationAuth.integration, integrationAuth: new Types.ObjectId(integrationAuthId) - }).save(); + }).save(); if (integration) { // trigger event - push secrets diff --git a/backend/src/controllers/v1/secretController.ts b/backend/src/controllers/v1/secretController.ts index 1d1f8981c..4b377ec72 100644 --- a/backend/src/controllers/v1/secretController.ts +++ b/backend/src/controllers/v1/secretController.ts @@ -9,7 +9,7 @@ import { import { pushKeys } from '../../helpers/key'; import { eventPushSecrets } from '../../events'; import { EventService } from '../../services'; -import { getPostHogClient } from '../../services'; +import { TelemetryService } from '../../services'; interface PushSecret { ciphertextKey: string; @@ -38,7 +38,7 @@ export const pushSecrets = async (req: Request, res: Response) => { // upload (encrypted) secrets to workspace with id [workspaceId] try { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); let { secrets }: { secrets: PushSecret[] } = req.body; const { keys, environment, channel } = req.body; const { workspaceId } = req.params; @@ -112,7 +112,7 @@ export const pullSecrets = async (req: Request, res: Response) => { let secrets; let key; try { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const environment: string = req.query.environment as string; const channel: string = req.query.channel as string; const { workspaceId } = req.params; @@ -181,7 +181,7 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => { let secrets; let key; try { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const environment: string = req.query.environment as string; const channel: string = req.query.channel as string; const { workspaceId } = req.params; diff --git a/backend/src/controllers/v2/apiKeyDataController.ts b/backend/src/controllers/v2/apiKeyDataController.ts index fd87f7306..e4450ae90 100644 --- a/backend/src/controllers/v2/apiKeyDataController.ts +++ b/backend/src/controllers/v2/apiKeyDataController.ts @@ -50,6 +50,7 @@ export const createAPIKeyData = async (req: Request, res: Response) => { apiKeyData = await new APIKeyData({ name, + lastUsed: new Date(), expiresAt, user: req.user._id, secretHash diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index b82dca9fe..4985420fb 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -11,7 +11,7 @@ import { import { SecretVersion } from '../../ee/models'; import { BadRequestError } from '../../utils/errors'; import _ from 'lodash'; -import { ABILITY_READ, ABILITY_WRITE } from '../../variables/organization'; +import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from '../../variables'; /** * Create new workspace environment named [environmentName] under workspace with id @@ -244,8 +244,8 @@ export const getAllAccessibleEnvironmentsOfWorkspace = async ( throw BadRequestError() } relatedWorkspace.environments.forEach(environment => { - const isReadBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: ABILITY_READ }) - const isWriteBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: ABILITY_WRITE }) + const isReadBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: PERMISSION_READ_SECRETS }) + const isWriteBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: PERMISSION_WRITE_SECRETS }) if (isReadBlocked && isWriteBlocked) { return } else { 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..613206ba3 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,45 @@ 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/secretController.ts b/backend/src/controllers/v2/secretController.ts index cd91dca67..91328ea7b 100644 --- a/backend/src/controllers/v2/secretController.ts +++ b/backend/src/controllers/v2/secretController.ts @@ -7,7 +7,7 @@ const { ValidationError } = mongoose.Error; import { BadRequestError, InternalServerError, UnauthorizedRequestError, ValidationError as RouteValidationError } from '../../utils/errors'; import { AnyBulkWriteOperation } from 'mongodb'; import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; -import { getPostHogClient } from '../../services'; +import { TelemetryService } from '../../services'; /** * Create secret for workspace with id [workspaceId] and environment [environment] @@ -15,7 +15,7 @@ import { getPostHogClient } from '../../services'; * @param res */ export const createSecret = async (req: Request, res: Response) => { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const secretToCreate: CreateSecretRequestBody = req.body.secret; const { workspaceId, environment } = req.params const sanitizedSecret: SanitizedSecretForCreate = { @@ -68,7 +68,7 @@ export const createSecret = async (req: Request, res: Response) => { * @param res */ export const createSecrets = async (req: Request, res: Response) => { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const secretsToCreate: CreateSecretRequestBody[] = req.body.secrets; const { workspaceId, environment } = req.params const sanitizedSecretesToCreate: SanitizedSecretForCreate[] = [] @@ -130,7 +130,7 @@ export const createSecrets = async (req: Request, res: Response) => { * @param res */ export const deleteSecrets = async (req: Request, res: Response) => { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const { workspaceId, environmentName } = req.params const secretIdsToDelete: string[] = req.body.secretIds @@ -184,7 +184,7 @@ export const deleteSecrets = async (req: Request, res: Response) => { * @param res */ export const deleteSecret = async (req: Request, res: Response) => { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); await Secret.findByIdAndDelete(req._secret._id) if (postHogClient) { @@ -213,7 +213,7 @@ export const deleteSecret = async (req: Request, res: Response) => { * @returns */ export const updateSecrets = async (req: Request, res: Response) => { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const { workspaceId, environmentName } = req.params const secretsModificationsRequested: ModifySecretRequestBody[] = req.body.secrets; const [secretIdsUserCanModifyError, secretIdsUserCanModify] = await to(Secret.find({ workspace: workspaceId, environment: environmentName }, { _id: 1 }).then()) @@ -281,7 +281,7 @@ export const updateSecrets = async (req: Request, res: Response) => { * @returns */ export const updateSecret = async (req: Request, res: Response) => { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const { workspaceId, environmentName } = req.params const secretModificationsRequested: ModifySecretRequestBody = req.body.secret; @@ -335,7 +335,7 @@ export const updateSecret = async (req: Request, res: Response) => { * @returns */ export const getSecrets = async (req: Request, res: Response) => { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const { environment } = req.query; const { workspaceId } = req.params; diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 66aecd423..82b5c65d6 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -15,12 +15,12 @@ import { UnauthorizedRequestError, ValidationError } from '../../utils/errors'; import { EventService } from '../../services'; import { eventPushSecrets } from '../../events'; import { EESecretService, EELogService } from '../../ee/services'; -import { getPostHogClient } from '../../services'; +import { TelemetryService } from '../../services'; import { getChannelFromUserAgent } from '../../utils/posthog'; -import { ABILITY_READ, ABILITY_WRITE } from '../../variables/organization'; +import { PERMISSION_WRITE_SECRETS } from '../../variables'; import { userHasNoAbility, userHasWorkspaceAccess, userHasWriteOnlyAbility } from '../../ee/helpers/checkMembershipPermissions'; import Tag from '../../models/tag'; -import _ from 'lodash'; +import _, { eq } from 'lodash'; import { BatchSecretRequest, BatchSecret @@ -28,12 +28,13 @@ import { /** * Peform a batch of any specified CUD secret operations + * (used by dashboard) * @param req * @param res */ export const batchSecrets = async (req: Request, res: Response) => { const channel = getChannelFromUserAgent(req.headers['user-agent']); - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const { workspaceId, @@ -91,7 +92,9 @@ export const batchSecrets = async (req: Request, res: Response) => { const addAction = await EELogService.createAction({ name: ACTION_ADD_SECRETS, - userId: req.user._id, + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(workspaceId), secretIds: createdSecrets.map((n) => n._id) }) as IAction; @@ -328,14 +331,15 @@ export const createSecrets = async (req: Request, res: Response) => { } } */ - const postHogClient = getPostHogClient(); const channel = getChannelFromUserAgent(req.headers['user-agent']) const { workspaceId, environment }: { workspaceId: string, environment: string } = req.body; - const hasAccess = await userHasWorkspaceAccess(req.user, workspaceId, environment, ABILITY_WRITE) - if (!hasAccess) { - throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) + if (req.user) { + const hasAccess = await userHasWorkspaceAccess(req.user, new Types.ObjectId(workspaceId), environment, PERMISSION_WRITE_SECRETS) + if (!hasAccess) { + throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) + } } let listOfSecretsToCreate; @@ -378,7 +382,7 @@ export const createSecrets = async (req: Request, res: Response) => { version: 1, workspace: new Types.ObjectId(workspaceId), type, - user: type === SECRET_PERSONAL ? req.user : undefined, + user: (req.user && type === SECRET_PERSONAL) ? req.user : undefined, environment, secretKeyCiphertext, secretKeyIV, @@ -391,7 +395,7 @@ export const createSecrets = async (req: Request, res: Response) => { secretCommentTag, tags }); - }) + }); const newlyCreatedSecrets: ISecret[] = (await Secret.insertMany(secretsToInsert)).map((insertedSecret) => insertedSecret.toObject()); @@ -447,14 +451,18 @@ export const createSecrets = async (req: Request, res: Response) => { const addAction = await EELogService.createAction({ name: ACTION_ADD_SECRETS, - userId: req.user._id, + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(workspaceId), secretIds: newlyCreatedSecrets.map((n) => n._id) }); // (EE) create (audit) log addAction && await EELogService.createLog({ - userId: req.user._id.toString(), + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(workspaceId), actions: [addAction], channel, @@ -466,10 +474,15 @@ export const createSecrets = async (req: Request, res: Response) => { workspaceId }); + const postHogClient = TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ event: 'secrets added', - distinctId: req.user.email, + distinctId: TelemetryService.getDistinctId({ + user: req.user, + serviceAccount: req.serviceAccount, + serviceTokenData: req.serviceTokenData + }), properties: { numberOfSecrets: listOfSecretsToCreate.length, environment, @@ -533,91 +546,118 @@ export const getSecrets = async (req: Request, res: Response) => { } */ - const postHogClient = getPostHogClient(); + const { tagSlugs } = req.query; + const workspaceId = req.query.workspaceId as string; + const environment = req.query.environment as string; - const { workspaceId, environment, tagSlugs } = req.query; + // tags logic + let tagIds = []; const tagNamesList = typeof tagSlugs === 'string' && tagSlugs !== '' ? tagSlugs.split(',') : []; - let userId = "" // used for getting personal secrets for user - let userEmail = "" // used for posthog - if (req.user) { - userId = req.user._id; - userEmail = req.user.email; - } - - if (req.serviceTokenData) { - userId = req.serviceTokenData.user._id - userEmail = req.serviceTokenData.user.email; - } - - // none service token case as service tokens are already scoped to env and project - let hasWriteOnlyAccess - if (!req.serviceTokenData) { - hasWriteOnlyAccess = await userHasWriteOnlyAbility(userId, workspaceId, environment) - const hasNoAccess = await userHasNoAbility(userId, workspaceId, environment) - if (hasNoAccess) { - throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) - } - } - let secrets: any - let secretQuery: any - if (tagNamesList != undefined && tagNamesList.length != 0) { - const workspaceFromDB = await Tag.find({ workspace: workspaceId }) - - const tagIds = _.map(tagNamesList, (tagName) => { + const workspaceFromDB = await Tag.find({ workspace: workspaceId }); + tagIds = _.map(tagNamesList, (tagName) => { const tag = _.find(workspaceFromDB, { slug: tagName }); return tag ? tag.id : null; }); + } + + let secrets: ISecret[] = []; + + if (req.user) { + // case: client authorization is via JWT - secretQuery = { - workspace: workspaceId, - environment, - $or: [ - { user: userId }, - { user: { $exists: false } } - ], - tags: { $in: tagIds }, - type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } + let hasWriteOnlyAccess + if (!req.serviceTokenData) { + hasWriteOnlyAccess = await userHasWriteOnlyAbility(req.user._id, new Types.ObjectId(workspaceId), environment) + const hasNoAccess = await userHasNoAbility(req.user._id, new Types.ObjectId(workspaceId), environment) + if (hasNoAccess) { + throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) + } } - } else { - secretQuery = { - workspace: workspaceId, - environment, - $or: [ - { user: userId }, - { user: { $exists: false } } - ], - type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } + + let secretQuery: any; + if (tagNamesList != undefined && tagNamesList.length != 0) { + const workspaceFromDB = await Tag.find({ workspace: workspaceId }) + + const tagIds = _.map(tagNamesList, (tagName) => { + const tag = _.find(workspaceFromDB, { slug: tagName }); + return tag ? tag.id : null; + }); + + secretQuery = { + workspace: workspaceId, + environment, + $or: [ + { user: req.user._id }, + { user: { $exists: false } } + ], + tags: { $in: tagIds }, + type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } + } + } else { + secretQuery = { + workspace: workspaceId, + environment, + $or: [ + { user: req.user._id }, + { user: { $exists: false } } + ], + type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } + } + } + + if (hasWriteOnlyAccess) { + // (i.e. you don't get values to decrypt since you can only write) + secrets = await Secret.find(secretQuery).select("secretKeyCiphertext secretKeyIV secretKeyTag") + } else { + secrets = await Secret.find(secretQuery).populate("tags") } } - - if (hasWriteOnlyAccess) { - secrets = await Secret.find(secretQuery).select("secretKeyCiphertext secretKeyIV secretKeyTag") - } else { - secrets = await Secret.find(secretQuery).populate("tags") + + if (req.serviceAccount || req.serviceTokenData) { + // case: client authorization is either via service account or service token + + secrets = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + environment, + user: { + $exists: false + }, + ...(tagIds.length > 0 ? { tags: { $in: tagIds } } : {}), + type: SECRET_SHARED + }); } const channel = getChannelFromUserAgent(req.headers['user-agent']) const readAction = await EELogService.createAction({ name: ACTION_READ_SECRETS, - userId: new Types.ObjectId(userId), + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(workspaceId as string), secretIds: secrets.map((n: any) => n._id) }); - + readAction && await EELogService.createLog({ - userId: new Types.ObjectId(userId), + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(workspaceId as string), actions: [readAction], channel, ipAddress: req.ip }); + const postHogClient = TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ event: 'secrets pulled', - distinctId: userEmail, + distinctId: TelemetryService.getDistinctId({ + user: req.user, + serviceAccount: req.serviceAccount, + serviceTokenData: req.serviceTokenData + }), properties: { numberOfSecrets: secrets.length, environment, @@ -633,59 +673,6 @@ export const getSecrets = async (req: Request, res: Response) => { }); } - -export const getOnlySecretKeys = async (req: Request, res: Response) => { - const { workspaceId, environment } = req.query; - - let userId = "" // used for getting personal secrets for user - let userEmail = "" // used for posthog - if (req.user) { - userId = req.user._id; - userEmail = req.user.email; - } - - if (req.serviceTokenData) { - userId = req.serviceTokenData.user._id - userEmail = req.serviceTokenData.user.email; - } - - // none service token case as service tokens are already scoped - if (!req.serviceTokenData) { - const hasAccess = await userHasWorkspaceAccess(userId, workspaceId, environment, ABILITY_READ) - if (!hasAccess) { - throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) - } - } - - const [err, secretKeys] = await to(Secret.find( - { - workspace: workspaceId, - environment, - $or: [ - { user: userId }, - { user: { $exists: false } } - ], - type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } - } - ) - .select("secretKeyIV secretKeyTag secretKeyCiphertext") - .then()) - - if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack }); - - // readAction && await EELogService.createLog({ - // userId: new Types.ObjectId(userId), - // workspaceId: new Types.ObjectId(workspaceId as string), - // actions: [readAction], - // channel, - // ipAddress: req.ip - // }); - - return res.status(200).send({ - secretKeys - }); -} - /** * Update secret(s) * @param req @@ -736,10 +723,8 @@ export const updateSecrets = async (req: Request, res: Response) => { } } */ - const postHogClient = getPostHogClient(); const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; - // TODO: move type interface PatchSecret { id: string; secretKeyCiphertext: string; @@ -865,14 +850,18 @@ export const updateSecrets = async (req: Request, res: Response) => { const updateAction = await EELogService.createAction({ name: ACTION_UPDATE_SECRETS, - userId: req.user._id, + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(key), secretIds: workspaceSecretObj[key].map((secret: ISecret) => secret._id) }); // (EE) create (audit) log updateAction && await EELogService.createLog({ - userId: req.user._id.toString(), + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(key), actions: [updateAction], channel, @@ -884,10 +873,15 @@ export const updateSecrets = async (req: Request, res: Response) => { workspaceId: key }) + const postHogClient = TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ event: 'secrets modified', - distinctId: req.user.email, + distinctId: TelemetryService.getDistinctId({ + user: req.user, + serviceAccount: req.serviceAccount, + serviceTokenData: req.serviceTokenData + }), properties: { numberOfSecrets: workspaceSecretObj[key].length, environment: workspaceSecretObj[key][0].environment, @@ -909,7 +903,7 @@ export const updateSecrets = async (req: Request, res: Response) => { } /** - * Delete secret(s) with id [workspaceId] and environment [environment] + * Delete secret(s) * @param req * @param res */ @@ -958,7 +952,11 @@ export const deleteSecrets = async (req: Request, res: Response) => { } } */ - const postHogClient = getPostHogClient(); + + return res.status(200).send({ + message: 'delete secrets!!' + }); + const channel = getChannelFromUserAgent(req.headers['user-agent']) const toDelete = req.secrets.map((s: any) => s._id); @@ -992,14 +990,18 @@ export const deleteSecrets = async (req: Request, res: Response) => { }); const deleteAction = await EELogService.createAction({ name: ACTION_DELETE_SECRETS, - userId: req.user._id, + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(key), secretIds: workspaceSecretObj[key].map((secret: ISecret) => secret._id) }); // (EE) create (audit) log deleteAction && await EELogService.createLog({ - userId: req.user._id.toString(), + userId: req.user?._id, + serviceAccountId: req.serviceAccount?._id, + serviceTokenDataId: req.serviceTokenData?._id, workspaceId: new Types.ObjectId(key), actions: [deleteAction], channel, @@ -1011,10 +1013,15 @@ export const deleteSecrets = async (req: Request, res: Response) => { workspaceId: key }) + const postHogClient = TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ event: 'secrets deleted', - distinctId: req.user.email, + distinctId: TelemetryService.getDistinctId({ + user: req.user, + serviceAccount: req.serviceAccount, + serviceTokenData: req.serviceTokenData + }), properties: { numberOfSecrets: workspaceSecretObj[key].length, environment: workspaceSecretObj[key][0].environment, diff --git a/backend/src/controllers/v2/serviceAccountsController.ts b/backend/src/controllers/v2/serviceAccountsController.ts new file mode 100644 index 000000000..7eaf73cea --- /dev/null +++ b/backend/src/controllers/v2/serviceAccountsController.ts @@ -0,0 +1,306 @@ +import { Request, Response } from 'express'; +import { Types } from 'mongoose'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import { + ServiceAccount, + ServiceAccountKey, + ServiceAccountOrganizationPermission, + ServiceAccountWorkspacePermission +} from '../../models'; +import { + CreateServiceAccountDto +} from '../../interfaces/serviceAccounts/dto'; +import { BadRequestError, ServiceAccountNotFoundError } from '../../utils/errors'; +import { getSaltRounds } from '../../config'; + +/** + * Return service account tied to the request (service account) client + * @param req + * @param res + */ +export const getCurrentServiceAccount = async (req: Request, res: Response) => { + const serviceAccount = await ServiceAccount.findById(req.serviceAccount._id); + + if (!serviceAccount) { + throw ServiceAccountNotFoundError({ message: 'Failed to find service account' }); + } + + return res.status(200).send({ + serviceAccount + }); +} + +/** + * Return service account with id [serviceAccountId] + * @param req + * @param res + */ +export const getServiceAccountById = 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 + */ +export const createServiceAccount = async (req: Request, res: Response) => { + const { + name, + organizationId, + publicKey, + expiresIn, + }: CreateServiceAccountDto = req.body; + + let expiresAt; + if (expiresIn) { + 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, + lastUsed: new Date(), + expiresAt, + secretHash + }).save(); + + const serviceAccountObj = serviceAccount.toObject(); + + delete serviceAccountObj.secretHash; + + // provision default org-level permission for service account + await new ServiceAccountOrganizationPermission({ + 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 + }); +} + +/** + * 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; +} + +/** + * Return workspace-level permission for service account with id [serviceAccountId] + * @param req + * @param res + */ +export const getServiceAccountWorkspacePermissions = async (req: Request, res: Response) => { + const serviceAccountWorkspacePermissions = await ServiceAccountWorkspacePermission.find({ + serviceAccount: req.serviceAccount._id + }).populate('workspace'); + + return res.status(200).send({ + serviceAccountWorkspacePermissions + }); +} + +/** + * Add a workspace permission 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, + read = false, + write = false, + encryptedKey, + nonce + } = 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 ServiceAccountWorkspacePermission.findOne({ + serviceAccount: new Types.ObjectId(serviceAccountId), + workspace: new Types.ObjectId(workspaceId), + environment + }); + + if (existingPermission) throw BadRequestError({ message: 'Failed to add workspace permission to service account due to already-existing ' }); + + const serviceAccountWorkspacePermission = await new ServiceAccountWorkspacePermission({ + serviceAccount: new Types.ObjectId(serviceAccountId), + workspace: new Types.ObjectId(workspaceId), + environment, + read, + write + }).save(); + + const existingServiceAccountKey = await ServiceAccountKey.findOne({ + serviceAccount: new Types.ObjectId(serviceAccountId), + workspace: new Types.ObjectId(workspaceId) + }); + + if (!existingServiceAccountKey) { + await new ServiceAccountKey({ + encryptedKey, + nonce, + sender: req.user._id, + serviceAccount: new Types.ObjectId(serviceAccountId), + workspace: new Types.ObjectId(workspaceId) + }).save(); + } + + return res.status(200).send({ + serviceAccountWorkspacePermission + }); +} + +/** + * Delete workspace permission from service account with id [serviceAccountId] + * @param req + * @param res + */ +export const deleteServiceAccountWorkspacePermission = async (req: Request, res: Response) => { + const { serviceAccountWorkspacePermissionId } = req.params; + const serviceAccountWorkspacePermission = await ServiceAccountWorkspacePermission.findByIdAndDelete(serviceAccountWorkspacePermissionId); + + if (serviceAccountWorkspacePermission) { + const { serviceAccount, workspace } = serviceAccountWorkspacePermission; + const count = await ServiceAccountWorkspacePermission.countDocuments({ + serviceAccount, + workspace + }); + + if (count === 0) { + await ServiceAccountKey.findOneAndDelete({ + serviceAccount, + workspace + }); + } + } + + return res.status(200).send({ + serviceAccountWorkspacePermission + }); +} + +/** + * 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); + + if (serviceAccount) { + await ServiceAccountKey.deleteMany({ + serviceAccount: serviceAccount._id + }); + + await ServiceAccountOrganizationPermission.deleteMany({ + serviceAccount: new Types.ObjectId(serviceAccountId) + }); + + await ServiceAccountWorkspacePermission.deleteMany({ + serviceAccount: new Types.ObjectId(serviceAccountId) + }); + } + + return res.status(200).send({ + serviceAccount + }); +} + +/** + * Return service account keys for service account with id [serviceAccountId] + * @param req + * @param res + * @returns + */ +export const getServiceAccountKeys = async (req: Request, res: Response) => { + const workspaceId = req.query.workspaceId as string; + + const serviceAccountKeys = await ServiceAccountKey.find({ + serviceAccount: req.serviceAccount._id, + ...(workspaceId ? { workspace: new Types.ObjectId(workspaceId) } : {}) + }); + + return res.status(200).send({ + serviceAccountKeys + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v2/serviceTokenDataController.ts b/backend/src/controllers/v2/serviceTokenDataController.ts index a4e06f8e4..6b3d24dfb 100644 --- a/backend/src/controllers/v2/serviceTokenDataController.ts +++ b/backend/src/controllers/v2/serviceTokenDataController.ts @@ -3,10 +3,16 @@ import { Request, Response } from 'express'; import crypto from 'crypto'; import bcrypt from 'bcrypt'; import { + User, + ServiceAccount, ServiceTokenData } from '../../models'; import { userHasWorkspaceAccess } from '../../ee/helpers/checkMembershipPermissions'; -import { ABILITY_READ } from '../../variables/organization'; +import { + PERMISSION_READ_SECRETS, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT +} from '../../variables'; import { getSaltRounds } from '../../config'; /** @@ -53,59 +59,57 @@ export const getServiceTokenData = async (req: Request, res: Response) => { * @returns */ export const createServiceTokenData = async (req: Request, res: Response) => { - let serviceToken, serviceTokenData; + let serviceTokenData; - try { - const { - name, - workspaceId, - environment, - encryptedKey, - iv, - tag, - expiresIn, - permissions - } = req.body; + const { + name, + workspaceId, + environment, + encryptedKey, + iv, + tag, + expiresIn, + permissions + } = req.body; - const hasAccess = await userHasWorkspaceAccess(req.user, workspaceId, environment, ABILITY_READ) - if (!hasAccess) { - throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) - } + const secret = crypto.randomBytes(16).toString('hex'); + const secretHash = await bcrypt.hash(secret, getSaltRounds()); - const secret = crypto.randomBytes(16).toString('hex'); - const secretHash = await bcrypt.hash(secret, getSaltRounds()); + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); - const expiresAt = new Date(); - expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); - - serviceTokenData = await new ServiceTokenData({ - name, - workspace: workspaceId, - environment, - user: req.user._id, - expiresAt, - secretHash, - encryptedKey, - iv, - tag, - permissions - }).save(); - - // return service token data without sensitive data - serviceTokenData = await ServiceTokenData.findById(serviceTokenData._id); - - if (!serviceTokenData) throw new Error('Failed to find service token data'); - - serviceToken = `st.${serviceTokenData._id.toString()}.${secret}`; - - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to create service token data' - }); + let user, serviceAccount; + + if (req.authData.authMode === AUTH_MODE_JWT && req.authData.authPayload instanceof User) { + user = req.authData.authPayload._id; } + if (req.authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && req.authData.authPayload instanceof ServiceAccount) { + serviceAccount = req.authData.authPayload._id; + } + + serviceTokenData = await new ServiceTokenData({ + name, + workspace: workspaceId, + environment, + user, + serviceAccount, + lastUsed: new Date(), + expiresAt, + secretHash, + encryptedKey, + iv, + tag, + permissions + }).save(); + + // return service token data without sensitive data + serviceTokenData = await ServiceTokenData.findById(serviceTokenData._id); + + if (!serviceTokenData) throw new Error('Failed to find service token data'); + + const serviceToken = `st.${serviceTokenData._id.toString()}.${secret}`; + return res.status(200).send({ serviceToken, serviceTokenData @@ -119,25 +123,11 @@ export const createServiceTokenData = async (req: Request, res: Response) => { * @returns */ export const deleteServiceTokenData = async (req: Request, res: Response) => { - let serviceTokenData; - try { - const { serviceTokenDataId } = req.params; + const { serviceTokenDataId } = req.params; - serviceTokenData = await ServiceTokenData.findByIdAndDelete(serviceTokenDataId); - - } catch (err) { - Sentry.setUser({ email: req.user.email }); - Sentry.captureException(err); - return res.status(400).send({ - message: 'Failed to delete service token data' - }); - } + const serviceTokenData = await ServiceTokenData.findByIdAndDelete(serviceTokenDataId); return res.status(200).send({ serviceTokenData }); -} - -function UnauthorizedRequestError(arg0: { message: string; }) { - throw new Error('Function not implemented.'); } \ No newline at end of file diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 650c70610..ec32dcbaf 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -19,7 +19,7 @@ import { reformatPullSecrets } from '../../helpers/secret'; import { pushKeys } from '../../helpers/key'; -import { getPostHogClient, EventService } from '../../services'; +import { TelemetryService, EventService } from '../../services'; import { eventPushSecrets } from '../../events'; interface V2PushSecret { @@ -48,7 +48,7 @@ interface V2PushSecret { export const pushWorkspaceSecrets = async (req: Request, res: Response) => { // upload (encrypted) secrets to workspace with id [workspaceId] try { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); let { secrets }: { secrets: V2PushSecret[] } = req.body; const { keys, environment, channel } = req.body; const { workspaceId } = req.params; @@ -122,7 +122,7 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { export const pullSecrets = async (req: Request, res: Response) => { let secrets; try { - const postHogClient = getPostHogClient(); + const postHogClient = TelemetryService.getPostHogClient(); const environment: string = req.query.environment as string; const channel: string = req.query.channel as string; const { workspaceId } = req.params; @@ -506,5 +506,4 @@ export const toggleAutoCapitalization = async (req: Request, res: Response) => { message: 'Successfully changed autoCapitalization setting', workspace }); -}; - +}; \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/membershipController.ts b/backend/src/ee/controllers/v1/membershipController.ts index fcf158ba8..35534d19c 100644 --- a/backend/src/ee/controllers/v1/membershipController.ts +++ b/backend/src/ee/controllers/v1/membershipController.ts @@ -2,7 +2,8 @@ import { Request, Response } from "express"; import { Membership, Workspace } from "../../../models"; import { IMembershipPermission } from "../../../models/membership"; import { BadRequestError, UnauthorizedRequestError } from "../../../utils/errors"; -import { ABILITY_READ, ABILITY_WRITE, ADMIN, MEMBER } from "../../../variables/organization"; +import { ADMIN, MEMBER } from "../../../variables/organization"; +import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from '../../../variables'; import { Builder } from "builder-pattern" import _ from "lodash"; @@ -10,7 +11,7 @@ export const denyMembershipPermissions = async (req: Request, res: Response) => const { membershipId } = req.params; const { permissions } = req.body; const sanitizedMembershipPermissions: IMembershipPermission[] = permissions.map((permission: IMembershipPermission) => { - if (!permission.ability || !permission.environmentSlug || ![ABILITY_READ, ABILITY_WRITE].includes(permission.ability)) { + if (!permission.ability || !permission.environmentSlug || ![PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS].includes(permission.ability)) { throw BadRequestError({ message: "One or more required fields are missing from the request or have incorrect type" }) } diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index ea9bb7dab..5a18ae08e 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -418,7 +418,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => { .skip(offset) .limit(limit) .populate('actions') - .populate('user'); + .populate('user serviceAccount serviceTokenData'); } catch (err) { Sentry.setUser({ email: req.user.email }); diff --git a/backend/src/ee/helpers/action.ts b/backend/src/ee/helpers/action.ts index eb94810f4..33111d4ba 100644 --- a/backend/src/ee/helpers/action.ts +++ b/backend/src/ee/helpers/action.ts @@ -24,11 +24,15 @@ import { const createActionUpdateSecret = async ({ name, userId, + serviceAccountId, + serviceTokenDataId, workspaceId, secretIds }: { name: string; - userId: Types.ObjectId; + userId?: Types.ObjectId; + serviceAccountId?: Types.ObjectId; + serviceTokenDataId?: Types.ObjectId; workspaceId: Types.ObjectId; secretIds: Types.ObjectId[]; }) => { @@ -46,6 +50,8 @@ const createActionUpdateSecret = async ({ action = await new Action({ name, user: userId, + serviceAccount: serviceAccountId, + serviceTokenData: serviceTokenDataId, workspace: workspaceId, payload: { secretVersions: latestSecretVersions @@ -72,11 +78,15 @@ const createActionUpdateSecret = async ({ const createActionSecret = async ({ name, userId, + serviceAccountId, + serviceTokenDataId, workspaceId, secretIds }: { name: string; - userId: Types.ObjectId; + userId?: Types.ObjectId; + serviceAccountId?: Types.ObjectId; + serviceTokenDataId?: Types.ObjectId; workspaceId: Types.ObjectId; secretIds: Types.ObjectId[]; }) => { @@ -94,6 +104,8 @@ const createActionSecret = async ({ action = await new Action({ name, user: userId, + serviceAccount: serviceAccountId, + serviceTokenData: serviceTokenDataId, workspace: workspaceId, payload: { secretVersions: latestSecretVersions @@ -110,29 +122,36 @@ const createActionSecret = async ({ } /** - * Create an (audit) action for user with id [userId] + * Create an (audit) action for client with id [userId], + * [serviceAccountId], or [serviceTokenDataId] * @param {Object} obj * @param {String} obj.name - name of action * @param {String} obj.userId - id of user associated with action * @returns */ -const createActionUser = ({ +const createActionClient = ({ name, - userId + userId, + serviceAccountId, + serviceTokenDataId }: { name: string; - userId: Types.ObjectId; + userId?: Types.ObjectId; + serviceAccountId?: Types.ObjectId; + serviceTokenDataId?: Types.ObjectId; }) => { let action; try { action = new Action({ name, - user: userId + user: userId, + serviceAccount: serviceAccountId, + serviceTokenData: serviceTokenDataId }).save(); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to create user action'); + throw new Error('Failed to create client action'); } return action; @@ -149,11 +168,15 @@ const createActionUser = ({ const createActionHelper = async ({ name, userId, + serviceAccountId, + serviceTokenDataId, workspaceId, secretIds, }: { name: string; - userId: Types.ObjectId; + userId?: Types.ObjectId; + serviceAccountId?: Types.ObjectId; + serviceTokenDataId?: Types.ObjectId; workspaceId?: Types.ObjectId; secretIds?: Types.ObjectId[]; }) => { @@ -162,7 +185,7 @@ const createActionHelper = async ({ switch (name) { case ACTION_LOGIN: case ACTION_LOGOUT: - action = await createActionUser({ + action = await createActionClient({ name, userId }); diff --git a/backend/src/ee/helpers/checkMembershipPermissions.ts b/backend/src/ee/helpers/checkMembershipPermissions.ts index 50cd28917..c97a51619 100644 --- a/backend/src/ee/helpers/checkMembershipPermissions.ts +++ b/backend/src/ee/helpers/checkMembershipPermissions.ts @@ -1,8 +1,9 @@ +import { Types } from 'mongoose'; import _ from "lodash"; import { Membership } from "../../models"; -import { ABILITY_READ, ABILITY_WRITE } from "../../variables/organization"; +import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from '../../variables'; -export const userHasWorkspaceAccess = async (userId: any, workspaceId: any, environment: any, action: any) => { +export const userHasWorkspaceAccess = async (userId: Types.ObjectId, workspaceId: Types.ObjectId, environment: string, action: any) => { const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId }) if (!membershipForWorkspace) { return false @@ -18,15 +19,15 @@ export const userHasWorkspaceAccess = async (userId: any, workspaceId: any, envi return true } -export const userHasWriteOnlyAbility = async (userId: any, workspaceId: any, environment: any) => { +export const userHasWriteOnlyAbility = async (userId: Types.ObjectId, workspaceId: Types.ObjectId, environment: string) => { const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId }) if (!membershipForWorkspace) { return false } const deniedMembershipPermissions = membershipForWorkspace.deniedPermissions; - const isWriteDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_WRITE }); - const isReadDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_READ }); + const isWriteDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: PERMISSION_WRITE_SECRETS }); + const isReadDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: PERMISSION_READ_SECRETS }); // case: you have write only if read is blocked and write is not if (isReadDisallowed && !isWriteDisallowed) { @@ -36,15 +37,15 @@ export const userHasWriteOnlyAbility = async (userId: any, workspaceId: any, env return false } -export const userHasNoAbility = async (userId: any, workspaceId: any, environment: any) => { +export const userHasNoAbility = async (userId: Types.ObjectId, workspaceId: Types.ObjectId, environment: string) => { const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId }) if (!membershipForWorkspace) { return true } const deniedMembershipPermissions = membershipForWorkspace.deniedPermissions; - const isWriteDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_WRITE }); - const isReadBlocked = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_READ }); + const isWriteDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: PERMISSION_WRITE_SECRETS }); + const isReadBlocked = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: PERMISSION_READ_SECRETS }); if (isReadBlocked && isWriteDisallowed) { return true diff --git a/backend/src/ee/helpers/log.ts b/backend/src/ee/helpers/log.ts index bdb0a2380..77d280ee1 100644 --- a/backend/src/ee/helpers/log.ts +++ b/backend/src/ee/helpers/log.ts @@ -16,12 +16,16 @@ import { */ const createLogHelper = async ({ userId, + serviceAccountId, + serviceTokenDataId, workspaceId, actions, channel, ipAddress }: { - userId: Types.ObjectId; + userId?: Types.ObjectId; + serviceAccountId?: Types.ObjectId; + serviceTokenDataId?: Types.ObjectId; workspaceId?: Types.ObjectId; actions: IAction[]; channel: string; @@ -31,6 +35,8 @@ const createLogHelper = async ({ try { log = await new Log({ user: userId, + serviceAccount: serviceAccountId, + serviceTokenData: serviceTokenDataId, workspace: workspaceId ?? undefined, actionNames: actions.map((a) => a.name), actions, diff --git a/backend/src/ee/middleware/requireSecretSnapshotAuth.ts b/backend/src/ee/middleware/requireSecretSnapshotAuth.ts index 5eae3721c..f43e9c315 100644 --- a/backend/src/ee/middleware/requireSecretSnapshotAuth.ts +++ b/backend/src/ee/middleware/requireSecretSnapshotAuth.ts @@ -30,8 +30,8 @@ const requireSecretSnapshotAuth = ({ } await validateMembership({ - userId: req.user._id.toString(), - workspaceId: secretSnapshot.workspace.toString(), + userId: req.user._id, + workspaceId: secretSnapshot.workspace, acceptedRoles }); diff --git a/backend/src/ee/models/action.ts b/backend/src/ee/models/action.ts index 7cdd7a355..055e144fd 100644 --- a/backend/src/ee/models/action.ts +++ b/backend/src/ee/models/action.ts @@ -11,6 +11,8 @@ import { export interface IAction { name: string; user?: Types.ObjectId, + serviceAccount?: Types.ObjectId, + serviceTokenData?: Types.ObjectId, workspace?: Types.ObjectId, payload?: { secretVersions?: Types.ObjectId[] @@ -33,8 +35,15 @@ const actionSchema = new Schema( }, user: { type: Schema.Types.ObjectId, - ref: 'User', - required: true + ref: 'User' + }, + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount' + }, + serviceTokenData: { + type: Schema.Types.ObjectId, + ref: 'ServiceTokenData' }, workspace: { type: Schema.Types.ObjectId, diff --git a/backend/src/ee/models/log.ts b/backend/src/ee/models/log.ts index 47be2e58f..9ed552640 100644 --- a/backend/src/ee/models/log.ts +++ b/backend/src/ee/models/log.ts @@ -11,6 +11,8 @@ import { export interface ILog { _id: Types.ObjectId; user?: Types.ObjectId; + serviceAccount?: Types.ObjectId; + serviceTokenData?: Types.ObjectId; workspace?: Types.ObjectId; actionNames: string[]; actions: Types.ObjectId[]; @@ -24,6 +26,14 @@ const logSchema = new Schema( type: Schema.Types.ObjectId, ref: 'User' }, + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount' + }, + serviceTokenData: { + type: Schema.Types.ObjectId, + ref: 'ServiceTokenData' + }, workspace: { type: Schema.Types.ObjectId, ref: 'Workspace' diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index a799d073b..722bfb4a7 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -15,7 +15,8 @@ router.get( acceptedAuthModes: ['jwt', 'apiKey'] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), query('offset').exists().isInt(), @@ -30,7 +31,8 @@ router.get( acceptedAuthModes: ['jwt'] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -43,7 +45,8 @@ router.post( acceptedAuthModes: ['jwt', 'apiKey'] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('version').exists().isInt(), @@ -57,7 +60,8 @@ router.get( acceptedAuthModes: ['jwt', 'apiKey'] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), query('offset').exists().isInt(), diff --git a/backend/src/ee/services/EELogService.ts b/backend/src/ee/services/EELogService.ts index bbe03e09e..81d26765f 100644 --- a/backend/src/ee/services/EELogService.ts +++ b/backend/src/ee/services/EELogService.ts @@ -26,12 +26,16 @@ class EELogService { */ static async createLog({ userId, + serviceAccountId, + serviceTokenDataId, workspaceId, actions, channel, ipAddress }: { - userId: Types.ObjectId; + userId?: Types.ObjectId; + serviceAccountId?: Types.ObjectId; + serviceTokenDataId?: Types.ObjectId; workspaceId?: Types.ObjectId; actions: IAction[]; channel: string; @@ -40,6 +44,8 @@ class EELogService { if (!EELicenseService.isLicenseValid) return null; return await createLogHelper({ userId, + serviceAccountId, + serviceTokenDataId, workspaceId, actions, channel, @@ -59,17 +65,23 @@ class EELogService { static async createAction({ name, userId, + serviceAccountId, + serviceTokenDataId, workspaceId, secretIds }: { name: string; - userId: Types.ObjectId; + userId?: Types.ObjectId; + serviceAccountId?: Types.ObjectId; + serviceTokenDataId?: Types.ObjectId; workspaceId?: Types.ObjectId; secretIds?: Types.ObjectId[]; }) { return await createActionHelper({ name, userId, + serviceAccountId, + serviceTokenDataId, workspaceId, secretIds }); diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index a08dcf2cc..c2c21ded5 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -1,15 +1,18 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; import { IUser, User, ServiceTokenData, + ServiceAccount, APIKeyData } from '../models'; import { AccountNotFoundError, ServiceTokenDataNotFoundError, + ServiceAccountNotFoundError, APIKeyDataNotFoundError, UnauthorizedRequestError, BadRequestError @@ -20,6 +23,12 @@ import { getJwtRefreshLifetime, getJwtRefreshSecret } from '../config'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; /** * @@ -37,7 +46,7 @@ const validateAuthMode = ({ const apiKey = headers['x-api-key']; const authHeader = headers['authorization']; - let authTokenType, authTokenValue; + let authMode, authTokenValue; if (apiKey === undefined && authHeader === undefined) { // case: no auth or X-API-KEY header present throw BadRequestError({ message: 'Missing Authorization or X-API-KEY in request header.' }); @@ -45,7 +54,7 @@ const validateAuthMode = ({ if (typeof apiKey === 'string') { // case: treat request authentication type as via X-API-KEY (i.e. API Key) - authTokenType = 'apiKey'; + authMode = AUTH_MODE_API_KEY; authTokenValue = apiKey; } @@ -61,20 +70,24 @@ const validateAuthMode = ({ switch (tokenValue.split('.', 1)[0]) { case 'st': - authTokenType = 'serviceToken'; + authMode = AUTH_MODE_SERVICE_TOKEN; + break; + case 'sa': + authMode = AUTH_MODE_SERVICE_ACCOUNT; break; default: - authTokenType = 'jwt'; + authMode = AUTH_MODE_JWT; } + authTokenValue = tokenValue; } - if (!authTokenType || !authTokenValue) throw BadRequestError({ message: 'Missing valid Authorization or X-API-KEY in request header.' }); + if (!authMode || !authTokenValue) throw BadRequestError({ message: 'Missing valid Authorization or X-API-KEY in request header.' }); - if (!acceptedAuthModes.includes(authTokenType)) throw BadRequestError({ message: 'The provided authentication type is not supported.' }); + if (!acceptedAuthModes.includes(authMode)) throw BadRequestError({ message: 'The provided authentication type is not supported.' }); return ({ - authTokenType, + authMode, authTokenValue }); } @@ -90,25 +103,17 @@ const getAuthUserPayload = async ({ }: { authTokenValue: string; }) => { - let user; - try { - const decodedToken = ( - jwt.verify(authTokenValue, getJwtAuthSecret()) - ); + const decodedToken = ( + jwt.verify(authTokenValue, getJwtAuthSecret()) + ); - user = await User.findOne({ - _id: decodedToken.userId - }).select('+publicKey'); + const user = await User.findOne({ + _id: decodedToken.userId + }).select('+publicKey'); - if (!user) throw AccountNotFoundError({ message: 'Failed to find User' }); + if (!user) throw AccountNotFoundError({ message: 'Failed to find User' }); - if (!user?.publicKey) throw UnauthorizedRequestError({ message: 'Failed to authenticate User with partially set up account' }); - - } catch (err) { - throw UnauthorizedRequestError({ - message: 'Failed to authenticate JWT token' - }); - } + if (!user?.publicKey) throw UnauthorizedRequestError({ message: 'Failed to authenticate User with partially set up account' }); return user; } @@ -124,45 +129,70 @@ const getAuthSTDPayload = async ({ }: { authTokenValue: string; }) => { - let serviceTokenData; - try { - const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3); + const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3); - // TODO: optimize double query - serviceTokenData = await ServiceTokenData - .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt'); + let serviceTokenData = await ServiceTokenData + .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt'); - if (!serviceTokenData) { - throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); - } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { - // case: service token expired - await ServiceTokenData.findByIdAndDelete(serviceTokenData._id); - throw UnauthorizedRequestError({ - message: 'Failed to authenticate expired service token' - }); - } - - const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash); - if (!isMatch) throw UnauthorizedRequestError({ - message: 'Failed to authenticate service token' - }); - - serviceTokenData = await ServiceTokenData - .findById(TOKEN_IDENTIFIER) - .select('+encryptedKey +iv +tag') - .populate<{user: IUser}>('user'); - - if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); - - } catch (err) { + if (!serviceTokenData) { + throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); + } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { + // case: service token expired + await ServiceTokenData.findByIdAndDelete(serviceTokenData._id); throw UnauthorizedRequestError({ - message: 'Failed to authenticate service token' + message: 'Failed to authenticate expired service token' }); } + const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash); + if (!isMatch) throw UnauthorizedRequestError({ + message: 'Failed to authenticate service token' + }); + + serviceTokenData = await ServiceTokenData + .findOneAndUpdate({ + _id: new Types.ObjectId(TOKEN_IDENTIFIER) + }, { + lastUsed: new Date() + }, { + new: true + }) + .select('+encryptedKey +iv +tag'); + + if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); + return serviceTokenData; } +/** + * Return service account access key payload + * @param {Object} obj + * @param {String} obj.authTokenValue - service account access token value + * @returns {ServiceAccount} serviceAccount + */ +const getAuthSAAKPayload = async ({ + authTokenValue +}: { + authTokenValue: string; +}) => { + const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3); + + const serviceAccount = await ServiceAccount.findById( + Buffer.from(TOKEN_IDENTIFIER, 'base64').toString('hex') + ).select('+secretHash'); + + if (!serviceAccount) { + throw ServiceAccountNotFoundError({ message: 'Failed to find service account' }); + } + + const result = await bcrypt.compare(TOKEN_SECRET, serviceAccount.secretHash); + if (!result) throw UnauthorizedRequestError({ + message: 'Failed to authenticate service account access key' + }); + + return serviceAccount; +} + /** * Return API key data payload corresponding to API key [authTokenValue] * @param {Object} obj @@ -174,33 +204,44 @@ const getAuthAPIKeyPayload = async ({ }: { authTokenValue: string; }) => { - let user; - try { - const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3); + const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3); - const apiKeyData = await APIKeyData - .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt') - .populate('user', '+publicKey'); + let apiKeyData = await APIKeyData + .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt') + .populate<{user: IUser}>('user', '+publicKey'); - if (!apiKeyData) { - throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' }); - } else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) { - // case: API key expired - await APIKeyData.findByIdAndDelete(apiKeyData._id); - throw UnauthorizedRequestError({ - message: 'Failed to authenticate expired API key' - }); - } - - const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash); - if (!isMatch) throw UnauthorizedRequestError({ - message: 'Failed to authenticate API key' - }); - - user = apiKeyData.user; - } catch (err) { + if (!apiKeyData) { + throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' }); + } else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) { + // case: API key expired + await APIKeyData.findByIdAndDelete(apiKeyData._id); throw UnauthorizedRequestError({ - message: 'Failed to authenticate API key' + message: 'Failed to authenticate expired API key' + }); + } + + const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash); + if (!isMatch) throw UnauthorizedRequestError({ + message: 'Failed to authenticate API key' + }); + + apiKeyData = await APIKeyData.findOneAndUpdate({ + _id: new Types.ObjectId(TOKEN_IDENTIFIER) + }, { + lastUsed: new Date() + }, { + new: true + }); + + if (!apiKeyData) { + throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' }); + } + + const user = await User.findById(apiKeyData.user).select('+publicKey'); + + if (!user) { + throw AccountNotFoundError({ + message: 'Failed to find user' }); } @@ -216,30 +257,23 @@ const getAuthAPIKeyPayload = async ({ * @return {String} obj.refreshToken - issued refresh token */ const issueAuthTokens = async ({ userId }: { userId: string }) => { - let token: string; - let refreshToken: string; - try { - // issue tokens - token = createToken({ - payload: { - userId - }, - expiresIn: getJwtAuthLifetime(), - secret: getJwtAuthSecret() - }); - refreshToken = createToken({ - payload: { - userId - }, - expiresIn: getJwtRefreshLifetime(), - secret: getJwtRefreshSecret() - }); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to issue tokens'); - } + // issue tokens + const token = createToken({ + payload: { + userId + }, + expiresIn: getJwtAuthLifetime(), + secret: getJwtAuthSecret() + }); + + const refreshToken = createToken({ + payload: { + userId + }, + expiresIn: getJwtRefreshLifetime(), + secret: getJwtRefreshSecret() + }); return { token, @@ -253,19 +287,14 @@ const issueAuthTokens = async ({ userId }: { userId: string }) => { * @param {String} obj.userId - id of user whose tokens are cleared. */ const clearTokens = async ({ userId }: { userId: string }): Promise => { - try { - // increment refreshVersion on user by 1 - User.findOneAndUpdate({ - _id: userId - }, { - $inc: { - refreshVersion: 1 - } - }); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - } + // increment refreshVersion on user by 1 + User.findOneAndUpdate({ + _id: userId + }, { + $inc: { + refreshVersion: 1 + } + }); }; /** @@ -285,21 +314,16 @@ const createToken = ({ expiresIn: string | number; secret: string; }) => { - try { - return jwt.sign(payload, secret, { - expiresIn - }); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to create a token'); - } + return jwt.sign(payload, secret, { + expiresIn + }); }; export { validateAuthMode, getAuthUserPayload, getAuthSTDPayload, + getAuthSAAKPayload, getAuthAPIKeyPayload, createToken, issueAuthTokens, diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 406162a8e..93c1ac7e9 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -1,5 +1,10 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; 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] @@ -14,28 +19,24 @@ const validateMembership = async ({ workspaceId, acceptedRoles, }: { - userId: string; - workspaceId: string; - acceptedRoles: string[]; + userId: Types.ObjectId; + workspaceId: Types.ObjectId; + 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'); - + const membership = await Membership.findOne({ + user: userId, + workspace: workspaceId + }).populate("workspace"); + + if (!membership) { + throw MembershipNotFoundError({ message: 'Failed to find workspace membership' }); + } + + if (acceptedRoles) { if (!acceptedRoles.includes(membership.role)) { - throw new Error('Failed to validate membership role'); + throw BadRequestError({ message: 'Failed to validate workspace membership role' }); } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to validate membership'); } 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/organization.ts b/backend/src/helpers/organization.ts index fb559df1b..0784f446b 100644 --- a/backend/src/helpers/organization.ts +++ b/backend/src/helpers/organization.ts @@ -1,14 +1,70 @@ import * as Sentry from '@sentry/node'; import Stripe from 'stripe'; import { Types } from 'mongoose'; -import { ACCEPTED } from '../variables'; +import { + IUser, + User, + IServiceAccount, + ServiceAccount, + IServiceTokenData, + ServiceTokenData +} from '../models'; import { Organization, MembershipOrg } from '../models'; +import { + ACCEPTED, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; import { getStripeSecretKey, getStripeProductPro, getStripeProductTeam, getStripeProductStarter } from '../config'; +import { + UnauthorizedRequestError +} from '../utils/errors'; + +/** + * Validate accepted clients for organization with id [organizationId] + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.organizationId - id of organization to validate against + */ +const validateClientForOrganization = async ({ + authData, + organizationId +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }, + organizationId: string; +}) => { + // TODO + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + // TODO + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + // TODO + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + // TODO + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + // TODO + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for organization resource' + }); +} /** * Create an organization with name [name] diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index c0f57f3ef..7a7979357 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -21,60 +21,8 @@ import { ACTION_READ_SECRETS } from '../variables'; import _ from 'lodash'; -import { ABILITY_WRITE } from '../variables/organization'; import { BadRequestError, UnauthorizedRequestError } from '../utils/errors'; -/** - * Validate that user with id [userId] can modify secrets with ids [secretIds] - * @param {Object} obj - * @param {Object} obj.userId - id of user to validate - * @param {Object} obj.secretIds - secret ids - * @returns {Secret[]} secrets - */ -const validateSecrets = async ({ - userId, - secretIds -}: { - userId: string; - secretIds: string[]; -}) => { - let secrets; - try { - secrets = await Secret.find({ - _id: { - $in: secretIds.map((secretId: string) => new Types.ObjectId(secretId)) - } - }); - - if (secrets.length != secretIds.length) { - throw BadRequestError({ message: 'Unable to validate some secrets' }) - } - - const userMemberships = await Membership.find({ user: userId }) - const userMembershipById = _.keyBy(userMemberships, 'workspace'); - const workspaceIdsSet = new Set(userMemberships.map((m) => m.workspace.toString())); - - // for each secret check if the secret belongs to a workspace the user is a member of - secrets.forEach((secret: ISecret) => { - if (workspaceIdsSet.has(secret.workspace.toString())) { - const deniedMembershipPermissions = userMembershipById[secret.workspace.toString()].deniedPermissions; - const isDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: secret.environment, ability: ABILITY_WRITE }); - - if (isDisallowed) { - throw UnauthorizedRequestError({ message: 'You do not have the required permissions to perform this action' }); - } - } else { - throw BadRequestError({ message: 'You cannot edit secrets of a workspace you are not a member of' }); - } - }); - - } catch (err) { - throw BadRequestError({ message: 'Unable to validate secrets' }) - } - - return secrets; -} - interface V1PushSecret { ciphertextKey: string; ivKey: string; @@ -714,7 +662,6 @@ const reformatPullSecrets = ({ secrets }: { secrets: ISecret[] }) => { }; export { - validateSecrets, v1PushSecrets, v2PushSecrets, pullSecrets, diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts new file mode 100644 index 000000000..09418560d --- /dev/null +++ b/backend/src/helpers/secrets.ts @@ -0,0 +1,109 @@ +import { Types } from 'mongoose'; +import { + User, + IUser, + ServiceAccount, + IServiceAccount, + ServiceTokenData, + IServiceTokenData, + Secret, + ISecret +} from '../models'; +import { + validateUserClientForSecrets +} from '../helpers/user'; +import { + validateServiceTokenDataClientForSecrets +} from '../helpers/serviceTokenData'; +import { + validateServiceAccountClientForSecrets +} from '../helpers/serviceAccount'; +import { BadRequestError, UnauthorizedRequestError } from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate accepted clients for secrets with ids [secretIds] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {ServiceAccount} obj.serviceAccount - service account client + * @param {ServiceTokenData} obj.service - service token client + * @param {String[]} obj.secretIds - ids of secrets to validate against + */ +const validateClientForSecrets = async ({ + authData, + secretIds, + requiredPermissions +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }, + secretIds: string[]; + requiredPermissions: string[]; +}) => { + + let secrets: ISecret[] = []; + + secrets = await Secret.find({ + _id: { + $in: secretIds.map((secretId: string) => new Types.ObjectId(secretId)) + } + }); + + if (secrets.length != secretIds.length) { + throw BadRequestError({ message: 'Failed to validate non-existent secrets' }) + } + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForSecrets({ + user: authData.authPayload, + secrets, + requiredPermissions + }); + + return secrets; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForSecrets({ + serviceAccount: authData.authPayload, + secrets, + requiredPermissions + }); + + return secrets; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForSecrets({ + serviceTokenData: authData.authPayload, + secrets, + requiredPermissions + }); + + return secrets; + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForSecrets({ + user: authData.authPayload, + secrets, + requiredPermissions + }); + + return secrets; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for secrets resource' + }); +} + +export { + validateClientForSecrets +} \ No newline at end of file diff --git a/backend/src/helpers/serviceAccount.ts b/backend/src/helpers/serviceAccount.ts new file mode 100644 index 000000000..f0fe3063e --- /dev/null +++ b/backend/src/helpers/serviceAccount.ts @@ -0,0 +1,239 @@ +import _ from 'lodash'; +import { Types } from 'mongoose'; +import { + User, + IUser, + ServiceAccount, + IServiceAccount, + ServiceTokenData, + IServiceTokenData, + ISecret, + ServiceAccountWorkspacePermission +} from '../models'; +import { + BadRequestError, + UnauthorizedRequestError, + ServiceAccountNotFoundError +} from '../utils/errors'; +import { + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; +import { + validateUserClientForServiceAccount +} from '../helpers/user'; + +const validateClientForServiceAccount = async ({ + authData, + serviceAccountId, + requiredPermissions +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }, + serviceAccountId: Types.ObjectId; + requiredPermissions?: string[]; +}) => { + const serviceAccount = await ServiceAccount.findById(serviceAccountId); + + if (!serviceAccount) { + throw ServiceAccountNotFoundError({ + message: 'Failed to find service account' + }); + } + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForServiceAccount({ + user: authData.authPayload, + serviceAccount, + requiredPermissions + }); + + return serviceAccount; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForServiceAccount({ + serviceAccount: authData.authPayload, + targetServiceAccount: serviceAccount, + requiredPermissions + }); + + return serviceAccount; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for service account resource' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForServiceAccount({ + user: authData.authPayload, + serviceAccount, + requiredPermissions + }); + + return serviceAccount; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for service account resource' + }); +} + +/** + * Validate that service account (client) can access workspace + * with id [workspaceId] and its environment [environment] with required permissions + * [requiredPermissions] + * @param {Object} obj + * @param {ServiceAccount} obj.serviceAccount - service account client + * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against + * @param {String} environment - (optional) environment in workspace to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ + const validateServiceAccountClientForWorkspace = async ({ + serviceAccount, + workspaceId, + environment, + requiredPermissions +}: { + serviceAccount: IServiceAccount; + workspaceId: Types.ObjectId; + environment?: string; + requiredPermissions?: string[]; +}) => { + // TODO: add service account API support for workspace-level endpoints that are not + // tied to any specific environment + + if (environment) { + const permission = await ServiceAccountWorkspacePermission.findOne({ + serviceAccount, + workspace: new Types.ObjectId(workspaceId), + environment + }); + + if (!permission) throw UnauthorizedRequestError({ + message: 'Failed service account authorization for the given workspace environment' + }); + + // TODO: refactor + let runningIsDisallowed = false; + requiredPermissions?.forEach((requiredPermission: string) => { + switch (requiredPermission) { + case PERMISSION_READ_SECRETS: + if (!permission.read) runningIsDisallowed = true; + break; + case PERMISSION_WRITE_SECRETS: + if (!permission.write) runningIsDisallowed = true; + break; + default: + break; + } + + if (runningIsDisallowed) { + throw UnauthorizedRequestError({ + message: `Failed permissions authorization for workspace environment action : ${requiredPermission}` + }); + } + }); + } +} + +/** + * Validate that service account (client) can access secrets + * with required permissions [requiredPermissions] + * @param {Object} obj + * @param {ServiceAccount} obj.serviceAccount - service account client + * @param {Secret[]} secrets - secrets to validate against + * @param {string[]} requiredPermissions - required permissions as part of the endpoint + */ + const validateServiceAccountClientForSecrets = async ({ + serviceAccount, + secrets, + requiredPermissions +}: { + serviceAccount: IServiceAccount; + secrets: ISecret[]; + requiredPermissions?: string[]; +}) => { + + const permissions = await ServiceAccountWorkspacePermission.find({ + serviceAccount: serviceAccount._id + }); + + const permissionsObj = _.keyBy(permissions, (p) => { + return `${p.workspace.toString()}-${p.environment}` + }); + + secrets.forEach((secret: ISecret) => { + const permission = permissionsObj[`${secret.workspace.toString()}-${secret.environment}`]; + + if (!permission) throw BadRequestError({ + message: 'Failed to find any permission for the secret workspace and environment' + }); + + requiredPermissions?.forEach((requiredPermission: string) => { + // TODO: refactor + let runningIsDisallowed = false; + requiredPermissions?.forEach((requiredPermission: string) => { + switch (requiredPermission) { + case PERMISSION_READ_SECRETS: + if (!permission.read) runningIsDisallowed = true; + break; + case PERMISSION_WRITE_SECRETS: + if (!permission.write) runningIsDisallowed = true; + break; + default: + break; + } + + if (runningIsDisallowed) { + throw UnauthorizedRequestError({ + message: `Failed permissions authorization for workspace environment action : ${requiredPermission}` + }); + } + }); + }); + }); + + // TODO + return []; +} + +/** + * Validate that service account (client) can access target service + * account [serviceAccount] with required permissions [requiredPermissions] + * @param {Object} obj + * @param {SerivceAccount} obj.serviceAccount - service account client + * @param {ServiceAccount} targetServiceAccount - target service account to validate against + * @param {string[]} requiredPermissions - required permissions as part of the endpoint + */ +const validateServiceAccountClientForServiceAccount = ({ + serviceAccount, + targetServiceAccount, + requiredPermissions +}: { + serviceAccount: IServiceAccount; + targetServiceAccount: IServiceAccount; + requiredPermissions?: string[]; +}) => { + if (!serviceAccount.organization.equals(targetServiceAccount.organization)) { + throw UnauthorizedRequestError({ + message: 'Failed service account authorization for the given service account' + }); + } +} + +export { + validateClientForServiceAccount, + validateServiceAccountClientForWorkspace, + validateServiceAccountClientForSecrets, + validateServiceAccountClientForServiceAccount +} \ No newline at end of file diff --git a/backend/src/helpers/serviceTokenData.ts b/backend/src/helpers/serviceTokenData.ts new file mode 100644 index 000000000..70c9d416b --- /dev/null +++ b/backend/src/helpers/serviceTokenData.ts @@ -0,0 +1,99 @@ +import { Types } from 'mongoose'; +import { + ISecret, + IServiceTokenData +} from '../models'; +import { UnauthorizedRequestError } from '../utils/errors'; + +/** + * Validate that service token (client) can access workspace + * with id [workspaceId] and its environment [environment] with required permissions + * [requiredPermissions] + * @param {Object} obj + * @param {ServiceTokenData} obj.serviceTokenData - service token client + * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against + * @param {String} environment - (optional) environment in workspace to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ + const validateServiceTokenDataClientForWorkspace = async ({ + serviceTokenData, + workspaceId, + environment, + requiredPermissions +}: { + serviceTokenData: IServiceTokenData; + workspaceId: Types.ObjectId; + environment?: string; + requiredPermissions?: string[]; +}) => { + + if (!serviceTokenData.workspace.equals(workspaceId)) { + // case: invalid workspaceId passed + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for the given workspace' + }); + } + + if (serviceTokenData.environment !== environment) { + // case: invalid environment passed + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for the given workspace environment' + }); + } + + requiredPermissions?.forEach((permission) => { + if (!serviceTokenData.permissions.includes(permission)) { + throw UnauthorizedRequestError({ + message: `Failed service token authorization for the given workspace environment action: ${permission}` + }); + } + }); +} + +/** + * Validate that service token (client) can access secrets + * with required permissions [requiredPermissions] + * @param {Object} obj + * @param {ServiceTokenData} obj.serviceTokenData - service token client + * @param {Secret[]} secrets - secrets to validate against + * @param {string[]} requiredPermissions - required permissions as part of the endpoint + */ + const validateServiceTokenDataClientForSecrets = async ({ + serviceTokenData, + secrets, + requiredPermissions +}: { + serviceTokenData: IServiceTokenData; + secrets: ISecret[]; + requiredPermissions?: string[]; +}) => { + + secrets.forEach((secret: ISecret) => { + if (!serviceTokenData.workspace.equals(secret.workspace)) { + // case: invalid workspaceId passed + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for the given workspace' + }); + } + + if (serviceTokenData.environment !== secret.environment) { + // case: invalid environment passed + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for the given workspace environment' + }); + } + + requiredPermissions?.forEach((permission) => { + if (!serviceTokenData.permissions.includes(permission)) { + throw UnauthorizedRequestError({ + message: `Failed service token authorization for the given workspace environment action: ${permission}` + }); + } + }); + }); +} + +export { + validateServiceTokenDataClientForWorkspace, + validateServiceTokenDataClientForSecrets +} \ No newline at end of file diff --git a/backend/src/helpers/telemetry.ts b/backend/src/helpers/telemetry.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index 932a4bd81..ddf313da9 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,6 +1,23 @@ import * as Sentry from '@sentry/node'; -import { IUser, User } from '../models'; +import { Types } from 'mongoose'; +import { + IUser, + ISecret, + IServiceAccount, + User, + Membership +} from '../models'; import { sendMail } from './nodemailer'; +import { validateMembership } from './membership'; +import _ from 'lodash'; +import { BadRequestError, UnauthorizedRequestError } from '../utils/errors'; +import { + validateMembershipOrg +} from '../helpers/membershipOrg'; +import { + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS +} from '../variables'; /** * Initialize a user under email [email] @@ -146,4 +163,136 @@ const checkUserDevice = async ({ } } -export { setupAccount, completeAccount, checkUserDevice }; +/** + * Validate that user (client) can access workspace + * with id [workspaceId] and its environment [environment] with required permissions + * [requiredPermissions] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against + * @param {String} environment - (optional) environment in workspace to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ +const validateUserClientForWorkspace = async ({ + user, + workspaceId, + environment, + requiredPermissions +}: { + user: IUser; + workspaceId: Types.ObjectId; + environment?: string; + requiredPermissions?: string[]; +}) => { + + // validate user membership in workspace + const membership = await validateMembership({ + userId: user._id, + workspaceId + }); + + // TODO: refactor + let runningIsDisallowed = false; + requiredPermissions?.forEach((requiredPermission: string) => { + switch (requiredPermission) { + case PERMISSION_READ_SECRETS: + runningIsDisallowed = _.some(membership.deniedPermissions, { environmentSlug: environment, ability: PERMISSION_READ_SECRETS }); + break; + case PERMISSION_WRITE_SECRETS: + runningIsDisallowed = _.some(membership.deniedPermissions, { environmentSlug: environment, ability: PERMISSION_WRITE_SECRETS }); + break; + default: + break; + } + + if (runningIsDisallowed) { + throw UnauthorizedRequestError({ + message: `Failed permissions authorization for workspace environment action : ${requiredPermission}` + }); + } + }); + + return membership; +} + +/** + * Validate that user (client) can access secrets [secrets] + * with required permissions [requiredPermissions] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {Secret[]} obj.secrets - secrets to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ + const validateUserClientForSecrets = async ({ + user, + secrets, + requiredPermissions +}: { + user: IUser; + secrets: ISecret[]; + requiredPermissions?: string[]; +}) => { + // TODO: refactor + + const userMemberships = await Membership.find({ user: user._id }) + const userMembershipById = _.keyBy(userMemberships, 'workspace'); + const workspaceIdsSet = new Set(userMemberships.map((m) => m.workspace.toString())); + + // for each secret check if the secret belongs to a workspace the user is a member of + secrets.forEach((secret: ISecret) => { + if (!workspaceIdsSet.has(secret.workspace.toString())) { + throw BadRequestError({ + message: 'Failed authorization for the secret' + }); + } + + if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { + const deniedMembershipPermissions = userMembershipById[secret.workspace.toString()].deniedPermissions; + const isDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: secret.environment, ability: PERMISSION_WRITE_SECRETS }); + + if (isDisallowed) { + throw UnauthorizedRequestError({ + message: 'You do not have the required permissions to perform this action' + }); + } + } + }); +} + +/** + * Validate that user (client) can access service account [serviceAccount] + * with required permissions [requiredPermissions] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {ServiceAccount} obj.serviceAccount - service account to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ +const validateUserClientForServiceAccount = async ({ + user, + serviceAccount, + requiredPermissions +}: { + user: IUser; + serviceAccount: IServiceAccount; + requiredPermissions?: string[]; +}) => { + if (!serviceAccount.user.equals(user._id)) { + // case: user who created service account is not the + // same user that is on the request + await validateMembershipOrg({ + userId: user._id, + organizationId: serviceAccount.organization, + acceptedRoles: [], + acceptedStatuses: [] + }); + } +} + +export { + setupAccount, + completeAccount, + checkUserDevice, + validateUserClientForWorkspace, + validateUserClientForSecrets, + validateUserClientForServiceAccount +}; diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index b43252bf3..7b077e7d1 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -1,12 +1,104 @@ import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Workspace, Bot, Membership, Key, - Secret + Secret, + User, + IUser, + ServiceAccountWorkspacePermission, + ServiceAccount, + IServiceAccount, + ServiceTokenData, + IServiceTokenData, } from '../models'; import { createBot } from '../helpers/bot'; +import { validateUserClientForWorkspace } from '../helpers/user'; +import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; +import { validateServiceTokenDataClientForWorkspace } from '../helpers/serviceTokenData'; +import { validateMembership } from '../helpers/membership'; +import { UnauthorizedRequestError } from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate authenticated clients for workspace with id [workspaceId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against + * @param {String} obj.environment - (optional) environment in workspace to validate against + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ +const validateClientForWorkspace = async ({ + authData, + workspaceId, + environment, + requiredPermissions +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }, + workspaceId: Types.ObjectId; + environment?: string; + requiredPermissions?: string[]; +}) => { + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + const membership = await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId, + environment, + requiredPermissions + }); + + return ({ membership }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId, + environment, + requiredPermissions + }); + + return {}; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload, + workspaceId, + environment, + requiredPermissions + }); + + return {}; + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + const membership = await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId, + environment, + requiredPermissions + }); + + return ({ membership }); + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for workspace resource' + }); +} /** * Create a workspace with name [name] in organization with id [organizationId] @@ -71,4 +163,8 @@ const deleteWorkspace = async ({ id }: { id: string }) => { } }; -export { createWorkspace, deleteWorkspace }; +export { + validateClientForWorkspace, + createWorkspace, + deleteWorkspace +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 03534a927..56fcc288a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,7 +9,7 @@ import * as Sentry from '@sentry/node'; import { DatabaseService } from './services'; import { setUpHealthEndpoint } from './services/health'; import { initSmtp } from './services/smtp'; -import { logTelemetryMessage } from './services'; +import { TelemetryService } from './services'; import { setTransporter } from './helpers/nodemailer'; import { createTestUserForDevelopment } from './utils/addDevelopmentUser'; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -48,17 +48,18 @@ import { integrationAuth as v1IntegrationAuthRouter } from './routes/v1'; import { - signup as v2SignupRouter, - auth as v2AuthRouter, - users as v2UsersRouter, - organizations as v2OrganizationsRouter, - workspace as v2WorkspaceRouter, - secret as v2SecretRouter, // begin to phase out - secrets as v2SecretsRouter, - serviceTokenData as v2ServiceTokenDataRouter, - apiKeyData as v2APIKeyDataRouter, - environment as v2EnvironmentRouter, - tags as v2TagsRouter, + signup as v2SignupRouter, + auth as v2AuthRouter, + users as v2UsersRouter, + organizations as v2OrganizationsRouter, + workspace as v2WorkspaceRouter, + 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, } from './routes/v2'; import { healthCheck } from './routes/status'; import { getLogger } from './utils/logger'; @@ -79,7 +80,7 @@ const main = async () => { }); } - logTelemetryMessage(); + TelemetryService.logTelemetryMessage(); setTransporter(initSmtp()); await DatabaseService.initDatabase(getMongoURL()); @@ -150,6 +151,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/integrations/apps.ts b/backend/src/integrations/apps.ts index e09946530..6929726e9 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -12,6 +12,7 @@ import { INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, @@ -20,6 +21,7 @@ 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, @@ -94,6 +96,11 @@ const getApps = async ({ accessToken, }); break; + case INTEGRATION_RAILWAY: + apps = await getAppsRailway({ + accessToken + }); + break; case INTEGRATION_FLYIO: apps = await getAppsFlyio({ accessToken, @@ -323,6 +330,58 @@ const getAppsRender = async ({ accessToken }: { accessToken: string }) => { return apps; }; +/** + * Return list of projects for Railway integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Railway API + * @returns {Object[]} apps - names and ids of Railway services + * @returns {String} apps.name - name of Railway project + * @returns {String} apps.appId - id of Railway project + * +*/ +const getAppsRailway = async ({ accessToken }: { accessToken: string }) => { + let apps: any[] = []; + try { + const query = ` + query GetProjects($userId: String, $teamId: String) { + projects(userId: $userId, teamId: $teamId) { + edges { + node { + id + name + } + } + } + } + `; + + const variables = {}; + + const { data: { data: { projects: { edges }}} } = await request.post(INTEGRATION_RAILWAY_API_URL, { + query, + variables, + }, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept-Encoding': 'application/json' + }, + }); + + apps = edges.map((e: any) => ({ + name: e.node.name, + appId: e.node.id + })); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to get Railway services"); + } + + return apps; +} + /** * Return list of apps for Fly.io integration * @param {Object} obj diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index ac2b27043..9a998d6f5 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,58 @@ 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, + ...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}), + replace: true, + variables: secrets + }; + + await request.post(INTEGRATION_RAILWAY_API_URL, { + query, + variables: { + input, + }, + }, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept-Encoding': '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/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/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..52d8d8342 --- /dev/null +++ b/backend/src/interfaces/serviceAccounts/dto/index.ts @@ -0,0 +1,7 @@ +import CreateServiceAccountDto from './CreateServiceAccountDto'; +import AddServiceAccountPermissionDto from './AddServiceAccountPermissionDto'; + +export { + CreateServiceAccountDto, + AddServiceAccountPermissionDto +} \ No newline at end of file diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 7e014103c..039b8612d 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -10,6 +10,8 @@ import requireIntegrationAuth from './requireIntegrationAuth'; import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizationAuth'; import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; +import requireServiceAccountAuth from './requireServiceAccountAuth'; +import requireServiceAccountWorkspacePermissionAuth from './requireServiceAccountWorkspacePermissionAuth'; import requireSecretAuth from './requireSecretAuth'; import requireSecretsAuth from './requireSecretsAuth'; import validateRequest from './validateRequest'; @@ -27,6 +29,8 @@ export { requireIntegrationAuthorizationAuth, requireServiceTokenAuth, requireServiceTokenDataAuth, + requireServiceAccountAuth, + requireServiceAccountWorkspacePermissionAuth, requireSecretAuth, requireSecretsAuth, validateRequest diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index f4921398a..fd828c89b 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -4,11 +4,23 @@ import { validateAuthMode, getAuthUserPayload, getAuthSTDPayload, - getAuthAPIKeyPayload + getAuthAPIKeyPayload, + getAuthSAAKPayload } from '../helpers/auth'; import { UnauthorizedRequestError } from '../utils/errors'; +import { + IUser, + IServiceAccount, + IServiceTokenData +} from '../models'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; declare module 'jsonwebtoken' { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -27,50 +39,57 @@ declare module 'jsonwebtoken' { * @returns */ const requireAuth = ({ - acceptedAuthModes = ['jwt'], - requiredServiceTokenPermissions = [] + acceptedAuthModes = [AUTH_MODE_JWT], }: { acceptedAuthModes: string[]; - requiredServiceTokenPermissions?: string[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { // validate auth token against accepted auth modes [acceptedAuthModes] // and return token type [authTokenType] and value [authTokenValue] - const { authTokenType, authTokenValue } = validateAuthMode({ + const { authMode, authTokenValue } = validateAuthMode({ headers: req.headers, acceptedAuthModes }); - // attach auth payloads - let serviceTokenData: any; - switch (authTokenType) { - case 'serviceToken': - serviceTokenData = await getAuthSTDPayload({ + let authPayload: IUser | IServiceAccount | IServiceTokenData; + switch (authMode) { + case AUTH_MODE_SERVICE_ACCOUNT: + authPayload = await getAuthSAAKPayload({ authTokenValue }); - - requiredServiceTokenPermissions.forEach((requiredServiceTokenPermission) => { - if (!serviceTokenData.permissions.includes(requiredServiceTokenPermission)) { - return next(UnauthorizedRequestError({ message: 'Failed to authorize service token for endpoint' })); - } - }); - - req.serviceTokenData = serviceTokenData; - req.user = serviceTokenData?.user; - + req.serviceAccount = authPayload; break; - case 'apiKey': - req.user = await getAuthAPIKeyPayload({ + case AUTH_MODE_SERVICE_TOKEN: + authPayload = await getAuthSTDPayload({ authTokenValue }); + req.serviceTokenData = authPayload; + break; + case AUTH_MODE_API_KEY: + authPayload = await getAuthAPIKeyPayload({ + authTokenValue + }); + req.user = authPayload; break; default: - req.user = await getAuthUserPayload({ + authPayload = await getAuthUserPayload({ authTokenValue }); + req.user = authPayload; break; } - + + req.requestData = { + ...req.params, + ...req.query, + ...req.body, + } + + req.authData = { + authMode, + authPayload + } + return next(); } } diff --git a/backend/src/middleware/requireBotAuth.ts b/backend/src/middleware/requireBotAuth.ts index 435b06a59..c06f1c861 100644 --- a/backend/src/middleware/requireBotAuth.ts +++ b/backend/src/middleware/requireBotAuth.ts @@ -20,8 +20,8 @@ const requireBotAuth = ({ } await validateMembership({ - userId: req.user._id.toString(), - workspaceId: bot.workspace.toString(), + userId: req.user._id, + workspaceId: bot.workspace, acceptedRoles }); diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts index b185b922b..51051584c 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -30,8 +30,8 @@ const requireIntegrationAuth = ({ } await validateMembership({ - userId: req.user._id.toString(), - workspaceId: integration.workspace.toString(), + userId: req.user._id, + workspaceId: integration.workspace, acceptedRoles }); diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index c712f4cca..07f347ecc 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -38,8 +38,8 @@ const requireIntegrationAuthorizationAuth = ({ } await validateMembership({ - userId: req.user._id.toString(), - workspaceId: integrationAuth.workspace._id.toString(), + userId: req.user._id, + workspaceId: integrationAuth.workspace._id, acceptedRoles }); diff --git a/backend/src/middleware/requireMembershipAuth.ts b/backend/src/middleware/requireMembershipAuth.ts index f5e4fe8b1..136fabadb 100644 --- a/backend/src/middleware/requireMembershipAuth.ts +++ b/backend/src/middleware/requireMembershipAuth.ts @@ -40,8 +40,8 @@ const requireMembershipAuth = ({ if (!userMembership) throw new Error('Failed to validate own membership') const targetMembership = await validateMembership({ - userId: req.user._id.toString(), - workspaceId: membership.workspace.toString(), + userId: req.user._id, + workspaceId: membership.workspace, acceptedRoles }); 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 04542b429..5768b4b36 100644 --- a/backend/src/middleware/requireOrganizationAuth.ts +++ b/backend/src/middleware/requireOrganizationAuth.ts @@ -1,44 +1,35 @@ 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'; /** * Validate if user on request is a member with proper roles for organization * 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, - acceptedStatuses + acceptedStatuses, + location = 'params' }: { acceptedRoles: string[]; acceptedStatuses: string[]; + location?: req; }) => { return async (req: Request, res: Response, next: NextFunction) => { - // organization authorization middleware - - // validate organization membership - const membershipOrg = await MembershipOrg.findOne({ - user: req.user._id, - organization: req.params.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; + const { organizationId } = req[location]; + req.membershipOrg = await validateMembershipOrg({ + userId: req.user._id, + organizationId: new Types.ObjectId(organizationId), + acceptedRoles, + acceptedStatuses + }); return next(); }; diff --git a/backend/src/middleware/requireSecretAuth.ts b/backend/src/middleware/requireSecretAuth.ts index 36e47247e..c86b7b680 100644 --- a/backend/src/middleware/requireSecretAuth.ts +++ b/backend/src/middleware/requireSecretAuth.ts @@ -32,8 +32,8 @@ const requireSecretAuth = ({ } await validateMembership({ - userId: req.user._id.toString(), - workspaceId: secret.workspace.toString(), + userId: req.user._id, + workspaceId: secret.workspace, acceptedRoles }); diff --git a/backend/src/middleware/requireSecretsAuth.ts b/backend/src/middleware/requireSecretsAuth.ts index 2a6c36056..5a550156a 100644 --- a/backend/src/middleware/requireSecretsAuth.ts +++ b/backend/src/middleware/requireSecretsAuth.ts @@ -1,48 +1,34 @@ import { Request, Response, NextFunction } from 'express'; import { UnauthorizedRequestError } from '../utils/errors'; import { Secret, Membership } from '../models'; -import { validateSecrets } from '../helpers/secret'; - -// TODO: make this work for delete route +import { validateClientForSecrets } from '../helpers/secrets'; const requireSecretsAuth = ({ - acceptedRoles + acceptedRoles, + requiredPermissions = [] }: { acceptedRoles: string[]; + requiredPermissions?: string[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { - let secrets; - try { - if (Array.isArray(req.body.secrets)) { - // case: validate multiple secrets - secrets = await validateSecrets({ - userId: req.user._id.toString(), - secretIds: req.body.secrets.map((s: any) => s.id) - }); - } else if (typeof req.body.secrets === 'object') { // change this to check for object - // case: validate 1 secret - secrets = await validateSecrets({ - userId: req.user._id.toString(), - secretIds: [req.body.secrets.id] - }); - } else if (Array.isArray(req.body.secretIds)) { - secrets = await validateSecrets({ - userId: req.user._id.toString(), - secretIds: req.body.secretIds - }); - } else if (typeof req.body.secretIds === 'string') { - // case: validate secretIds - secrets = await validateSecrets({ - userId: req.user._id.toString(), - secretIds: [req.body.secretIds] - }); - } - - req.secrets = secrets; - return next(); - } catch (err) { - return next(UnauthorizedRequestError({ message: 'Unable to authenticate secret(s)' })); + let secretIds = []; + if (Array.isArray(req.body.secrets)) { + secretIds = req.body.secrets.map((s: any) => s.id); + } else if (typeof req.body.secrets === 'object') { + secretIds = [req.body.secrets.id]; + } else if (Array.isArray(req.body.secretIds)) { + secretIds = req.body.secretIds; + } else if (typeof req.body.secretIds === 'string') { + secretIds = [req.body.secretIds]; } + + req.secrets = await validateClientForSecrets({ + authData: req.authData, + secretIds: [req.body.secretIds], + requiredPermissions + }); + + return next(); } } diff --git a/backend/src/middleware/requireServiceAccountAuth.ts b/backend/src/middleware/requireServiceAccountAuth.ts new file mode 100644 index 000000000..40861a737 --- /dev/null +++ b/backend/src/middleware/requireServiceAccountAuth.ts @@ -0,0 +1,40 @@ +import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; +import { ServiceAccount } from '../models'; +import { + ServiceAccountNotFoundError +} from '../utils/errors'; +import { + validateMembershipOrg +} from '../helpers/membershipOrg'; +import { + validateClientForServiceAccount +} from '../helpers/serviceAccount'; + +type req = 'params' | 'body' | 'query'; + +const requireServiceAccountAuth = ({ + acceptedRoles, + acceptedStatuses, + locationServiceAccountId = 'params', + requiredPermissions = [] +}: { + acceptedRoles: string[]; + acceptedStatuses: string[]; + locationServiceAccountId?: req; + requiredPermissions?: string[]; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + const serviceAccountId = req[locationServiceAccountId].serviceAccountId; + + req.serviceAccount = await validateClientForServiceAccount({ + authData: req.authData, + serviceAccountId: new Types.ObjectId(serviceAccountId), + requiredPermissions + }); + + next(); + } +} + +export default requireServiceAccountAuth; \ No newline at end of file diff --git a/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts b/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts new file mode 100644 index 000000000..d28c72352 --- /dev/null +++ b/backend/src/middleware/requireServiceAccountWorkspacePermissionAuth.ts @@ -0,0 +1,52 @@ +import { Request, Response, NextFunction } from 'express'; +import { ServiceAccount, ServiceAccountWorkspacePermission } from '../models'; +import { + ServiceAccountNotFoundError +} from '../utils/errors'; +import { + validateMembershipOrg +} from '../helpers/membershipOrg'; + +type req = 'params' | 'body' | 'query'; + +const requireServiceAccountWorkspacePermissionAuth = ({ + acceptedRoles, + acceptedStatuses, + location = 'params' +}: { + acceptedRoles: string[]; + acceptedStatuses: string[]; + location?: req; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + const serviceAccountWorkspacePermissionId = req[location].serviceAccountWorkspacePermissionId; + const serviceAccountWorkspacePermission = await ServiceAccountWorkspacePermission.findById(serviceAccountWorkspacePermissionId); + + if (!serviceAccountWorkspacePermission) { + return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account workspace permission' })); + } + + const serviceAccount = await ServiceAccount.findById(serviceAccountWorkspacePermission.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 requireServiceAccountWorkspacePermissionAuth; \ No newline at end of file diff --git a/backend/src/middleware/requireServiceTokenDataAuth.ts b/backend/src/middleware/requireServiceTokenDataAuth.ts index 7c5ab4669..513cbe604 100644 --- a/backend/src/middleware/requireServiceTokenDataAuth.ts +++ b/backend/src/middleware/requireServiceTokenDataAuth.ts @@ -26,8 +26,8 @@ const requireServiceTokenDataAuth = ({ if (req.user) { // case: jwt auth await validateMembership({ - userId: req.user._id.toString(), - workspaceId: serviceTokenData.workspace.toString(), + userId: req.user._id, + workspaceId: serviceTokenData.workspace, acceptedRoles }); } diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 56c24528c..64c1c37f2 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -1,5 +1,7 @@ import { Request, Response, NextFunction } from 'express'; +import { Types } from 'mongoose'; import { validateMembership } from '../helpers/membership'; +import { validateClientForWorkspace } from '../helpers/workspace'; import { UnauthorizedRequestError } from '../utils/errors'; type req = 'params' | 'body' | 'query'; @@ -13,38 +15,33 @@ type req = 'params' | 'body' | 'query'; */ const requireWorkspaceAuth = ({ acceptedRoles, - location = 'params' + locationWorkspaceId, + locationEnvironment = undefined, + requiredPermissions = [] }: { acceptedRoles: string[]; - location?: req; + locationWorkspaceId: req; + locationEnvironment?: req | undefined; + requiredPermissions?: string[]; }) => { return async (req: Request, res: Response, next: NextFunction) => { - try { - const { workspaceId } = req[location]; - - if (req.user) { - // case: jwt auth - const membership = await validateMembership({ - userId: req.user._id.toString(), - workspaceId, - acceptedRoles - }); - - req.membership = membership; - } - - if ( - req.serviceTokenData - && req.serviceTokenData.workspace.toString() !== workspaceId - && req.serviceTokenData.environment !== req.body.environment - ) { - next(UnauthorizedRequestError({message: 'Unable to authenticate workspace'})) - } - - return next(); - } catch (err) { - return next(UnauthorizedRequestError({message: 'Unable to authenticate workspace'})) + + const workspaceId = req[locationWorkspaceId]?.workspaceId; + const environment = locationEnvironment ? req[locationEnvironment]?.environment : undefined; + + // validate clients + const { membership } = await validateClientForWorkspace({ + authData: req.authData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + requiredPermissions + }); + + if (membership) { + req.membership = membership; } + + return next(); }; }; diff --git a/backend/src/models/apiKeyData.ts b/backend/src/models/apiKeyData.ts index af73b5f69..1b6831730 100644 --- a/backend/src/models/apiKeyData.ts +++ b/backend/src/models/apiKeyData.ts @@ -3,6 +3,7 @@ import { Schema, model, Types } from 'mongoose'; export interface IAPIKeyData { name: string; user: Types.ObjectId; + lastUsed: Date; expiresAt: Date; secretHash: string; } @@ -18,6 +19,9 @@ const apiKeyDataSchema = new Schema( ref: 'User', required: true }, + lastUsed: { + type: Date + }, expiresAt: { type: Date }, diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index ead32aae4..886f7fec6 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -10,6 +10,10 @@ 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 ServiceAccountOrganizationPermission, { IServiceAccountOrganizationPermission } from './serviceAccountOrganizationPermission'; // new +import ServiceAccountWorkspacePermission, { IServiceAccountWorkspacePermission } from './serviceAccountWorkspacePermission'; // new import TokenData, { ITokenData } from './tokenData'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; @@ -43,6 +47,14 @@ export { ISecret, ServiceToken, IServiceToken, + ServiceAccount, + IServiceAccount, + ServiceAccountKey, + IServiceAccountKey, + ServiceAccountOrganizationPermission, + IServiceAccountOrganizationPermission, + ServiceAccountWorkspacePermission, + IServiceAccountWorkspacePermission, TokenData, ITokenData, User, diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 33cd848bb..22a8713e0 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -9,6 +9,7 @@ import { INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, @@ -20,9 +21,12 @@ export interface IIntegration { environment: string; isActive: boolean; app: string; + appId: string; owner: string; targetEnvironment: string; - appId: string; + targetEnvironmentId: string; + targetService: string; + targetServiceId: string; path: string; region: string; integration: @@ -35,6 +39,7 @@ export interface IIntegration { | 'github' | 'gitlab' | 'render' + | 'railway' | 'flyio' | 'circleci' | 'travisci'; @@ -71,6 +76,20 @@ const integrationSchema = new Schema( type: String, default: null, }, + targetEnvironmentId: { + type: String, + default: null + }, + targetService: { + // railway-specific service + type: String, + default: null + }, + targetServiceId: { + // railway-specific service + type: String, + default: null + }, owner: { // github-specific repo owner-login type: String, @@ -99,6 +118,7 @@ const integrationSchema = new Schema( INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 43bef63ad..64c4f2d69 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -9,6 +9,7 @@ import { INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, @@ -17,7 +18,7 @@ import { export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'aws-parameter-store' | 'aws-secret-manager'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'aws-parameter-store' | 'aws-secret-manager'; teamId: string; accountId: string; refreshCiphertext?: string; @@ -51,6 +52,7 @@ const integrationAuthSchema = new Schema( INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, diff --git a/backend/src/models/serviceAccount.ts b/backend/src/models/serviceAccount.ts new file mode 100644 index 000000000..9ff9dcb03 --- /dev/null +++ b/backend/src/models/serviceAccount.ts @@ -0,0 +1,53 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface IServiceAccount extends Document { + _id: Types.ObjectId; + name: string; + organization: Types.ObjectId; + user: Types.ObjectId; + publicKey: string; + lastUsed: Date; + expiresAt: Date; + secretHash: string; +} + +const serviceAccountSchema = new Schema( + { + name: { + type: String, + required: true + }, + organization: { + type: Schema.Types.ObjectId, + ref: 'Organization', + required: true + }, + user: { // user who created the service account + type: Schema.Types.ObjectId, + ref: 'User', + required: true + }, + publicKey: { + type: String, + required: true + }, + lastUsed: { + type: Date + }, + expiresAt: { + type: Date + }, + secretHash: { + type: String, + required: true, + select: false + } + }, + { + timestamps: true + } +); + +const ServiceAccount = model('ServiceAccount', 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..637ac188b --- /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 serviceAccountKeySchema = 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', serviceAccountKeySchema); + +export default ServiceAccountKey; diff --git a/backend/src/models/serviceAccountOrganizationPermission.ts b/backend/src/models/serviceAccountOrganizationPermission.ts new file mode 100644 index 000000000..6454bc6a0 --- /dev/null +++ b/backend/src/models/serviceAccountOrganizationPermission.ts @@ -0,0 +1,23 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface IServiceAccountOrganizationPermission extends Document { + _id: Types.ObjectId; + serviceAccount: Types.ObjectId; +} + +const serviceAccountOrganizationPermissionSchema = new Schema( + { + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount', + required: true + } + }, + { + timestamps: true + } +); + +const ServiceAccountOrganizationPermission = model('ServiceAccountOrganizationPermission', serviceAccountOrganizationPermissionSchema); + +export default ServiceAccountOrganizationPermission; \ No newline at end of file diff --git a/backend/src/models/serviceAccountWorkspacePermission.ts b/backend/src/models/serviceAccountWorkspacePermission.ts new file mode 100644 index 000000000..01e4c4ba6 --- /dev/null +++ b/backend/src/models/serviceAccountWorkspacePermission.ts @@ -0,0 +1,44 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface IServiceAccountWorkspacePermission extends Document { + _id: Types.ObjectId; + serviceAccount: Types.ObjectId; + workspace: Types.ObjectId; + environment: string; + read: boolean; + write: boolean; +} + +const serviceAccountWorkspacePermissionSchema = 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 + }, + read: { + type: Boolean, + default: false + }, + write: { + type: Boolean, + default: false + } + }, + { + timestamps: true + } +); + +const ServiceAccountWorkspacePermission = model('ServiceAccountWorkspacePermission', serviceAccountWorkspacePermissionSchema); + +export default ServiceAccountWorkspacePermission; \ No newline at end of file diff --git a/backend/src/models/serviceTokenData.ts b/backend/src/models/serviceTokenData.ts index 2467b0b82..86f0e44b3 100644 --- a/backend/src/models/serviceTokenData.ts +++ b/backend/src/models/serviceTokenData.ts @@ -1,10 +1,13 @@ -import { Schema, model, Types } from 'mongoose'; +import { Schema, model, Types, Document } from 'mongoose'; -export interface IServiceTokenData { +export interface IServiceTokenData extends Document { + _id: Types.ObjectId; name: string; workspace: Types.ObjectId; environment: string; user: Types.ObjectId; + serviceAccount: Types.ObjectId; + lastUsed: Date; expiresAt: Date; secretHash: string; encryptedKey: string; @@ -24,14 +27,20 @@ const serviceTokenDataSchema = new Schema( ref: 'Workspace', required: true }, - environment: { // TODO: adapt to upcoming environment id + environment: { type: String, required: true }, user: { type: Schema.Types.ObjectId, - ref: 'User', - required: true + ref: 'User' + }, + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount' + }, + lastUsed: { + type: Date }, expiresAt: { type: Date diff --git a/backend/src/routes/v1/auth.ts b/backend/src/routes/v1/auth.ts index 638e4501b..a3558f341 100644 --- a/backend/src/routes/v1/auth.ts +++ b/backend/src/routes/v1/auth.ts @@ -4,6 +4,7 @@ import { body } from 'express-validator'; import { requireAuth, validateRequest } from '../../middleware'; import { authController } from '../../controllers/v1'; import { authLimiter } from '../../helpers/rateLimiter'; +import { AUTH_MODE_JWT } from '../../variables'; router.post('/token', validateRequest, authController.getNewToken); @@ -29,7 +30,7 @@ router.post( '/logout', authLimiter, requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), authController.logout ); @@ -37,7 +38,7 @@ router.post( router.post( '/checkAuth', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), authController.checkAuth ); diff --git a/backend/src/routes/v1/bot.ts b/backend/src/routes/v1/bot.ts index 4d3865562..83e126dc4 100644 --- a/backend/src/routes/v1/bot.ts +++ b/backend/src/routes/v1/bot.ts @@ -8,15 +8,16 @@ import { validateRequest } from '../../middleware'; import { botController } from '../../controllers/v1'; -import { ADMIN, MEMBER } from '../../variables'; +import { ADMIN, MEMBER, AUTH_MODE_JWT } from '../../variables'; router.get( '/:workspaceId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim().notEmpty(), validateRequest, @@ -26,7 +27,7 @@ router.get( router.patch( '/:botId/active', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireBotAuth({ acceptedRoles: [ADMIN, MEMBER] diff --git a/backend/src/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index 72d818b61..52930ce0c 100644 --- a/backend/src/routes/v1/integration.ts +++ b/backend/src/routes/v1/integration.ts @@ -6,14 +6,19 @@ import { requireIntegrationAuthorizationAuth, validateRequest } from '../../middleware'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT, + AUTH_MODE_API_KEY +} from '../../variables'; import { body, param } from 'express-validator'; import { integrationController } from '../../controllers/v1'; router.post( // new: add new integration for integration auth '/', requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -25,6 +30,9 @@ router.post( // new: add new integration for integration auth body('appId').trim(), body('sourceEnvironment').trim(), body('targetEnvironment').trim(), + body('targetEnvironmentId').trim(), + body('targetService').trim(), + body('targetServiceId').trim(), body('owner').trim(), body('path').trim(), body('region').trim(), @@ -35,7 +43,7 @@ router.post( // new: add new integration for integration auth router.patch( '/:integrationId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireIntegrationAuth({ acceptedRoles: [ADMIN, MEMBER] @@ -54,7 +62,7 @@ router.patch( router.delete( '/:integrationId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireIntegrationAuth({ acceptedRoles: [ADMIN, MEMBER] diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index a5a2d2882..fa5863029 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -7,13 +7,18 @@ import { requireIntegrationAuthorizationAuth, validateRequest } from '../../middleware'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT, + AUTH_MODE_API_KEY +} from '../../variables'; import { integrationAuthController } from '../../controllers/v1'; router.get( '/integration-options', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), integrationAuthController.getIntegrationOptions ); @@ -21,7 +26,7 @@ router.get( router.get( '/:integrationAuthId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER] @@ -34,11 +39,11 @@ router.get( router.post( '/oauth-token', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - location: 'body' + locationWorkspaceId: 'body' }), body('workspaceId').exists().trim().notEmpty(), body('code').exists().trim().notEmpty(), @@ -49,25 +54,25 @@ router.post( router.post( '/access-token', - requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] - }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - location: 'body' - }), body('workspaceId').exists().trim().notEmpty(), body('accessId').trim(), body('accessToken').exists().trim().notEmpty(), body('integration').exists().trim().notEmpty(), validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'body' + }), integrationAuthController.saveIntegrationAccessToken ); router.get( '/:integrationAuthId/apps', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER] @@ -81,7 +86,7 @@ router.get( router.get( '/:integrationAuthId/teams', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER] @@ -106,10 +111,38 @@ 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.get( + '/:integrationAuthId/railway/services', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireIntegrationAuthorizationAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + param('integrationAuthId').exists().isString(), + query('appId').exists().isString(), + validateRequest, + integrationAuthController.getIntegrationAuthRailwayServices +); + router.delete( '/:integrationAuthId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireIntegrationAuthorizationAuth({ acceptedRoles: [ADMIN, MEMBER], diff --git a/backend/src/routes/v1/inviteOrg.ts b/backend/src/routes/v1/inviteOrg.ts index d8e2d67cc..4762711fe 100644 --- a/backend/src/routes/v1/inviteOrg.ts +++ b/backend/src/routes/v1/inviteOrg.ts @@ -3,11 +3,12 @@ const router = express.Router(); import { body } from 'express-validator'; import { requireAuth, validateRequest } from '../../middleware'; import { membershipOrgController } from '../../controllers/v1'; +import { AUTH_MODE_JWT } from '../../variables'; router.post( '/signup', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('inviteeEmail').exists().trim().notEmpty().isEmail(), body('organizationId').exists().trim().notEmpty(), diff --git a/backend/src/routes/v1/key.ts b/backend/src/routes/v1/key.ts index b66bd1276..be99c9c17 100644 --- a/backend/src/routes/v1/key.ts +++ b/backend/src/routes/v1/key.ts @@ -6,16 +6,17 @@ import { validateRequest } from '../../middleware'; import { body, param } from 'express-validator'; -import { ADMIN, MEMBER } from '../../variables'; +import { ADMIN, MEMBER, AUTH_MODE_JWT } from '../../variables'; import { keyController } from '../../controllers/v1'; router.post( '/:workspaceId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('key').exists(), @@ -26,10 +27,11 @@ router.post( router.get( '/:workspaceId/latest', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId'), validateRequest, diff --git a/backend/src/routes/v1/membership.ts b/backend/src/routes/v1/membership.ts index aaacada74..e830bd06d 100644 --- a/backend/src/routes/v1/membership.ts +++ b/backend/src/routes/v1/membership.ts @@ -4,13 +4,14 @@ import { body, param } from 'express-validator'; import { requireAuth, validateRequest } from '../../middleware'; import { membershipController } from '../../controllers/v1'; import { membershipController as EEMembershipControllers } from '../../ee/controllers/v1'; +import { AUTH_MODE_JWT } from '../../variables'; // note: ALL DEPRECIATED (moved to api/v2/workspace/:workspaceId/memberships/:membershipId) router.get( // used for old CLI (deprecate) '/:workspaceId/connect', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), param('workspaceId').exists().trim(), validateRequest, @@ -20,7 +21,7 @@ router.get( // used for old CLI (deprecate) router.delete( '/:membershipId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), param('membershipId').exists().trim(), validateRequest, @@ -30,7 +31,7 @@ router.delete( router.post( '/:membershipId/change-role', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('role').exists().trim(), validateRequest, @@ -40,7 +41,7 @@ router.post( router.post( '/:membershipId/deny-permissions', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), param('membershipId').isMongoId().exists().trim(), body('permissions').isArray().exists(), diff --git a/backend/src/routes/v1/membershipOrg.ts b/backend/src/routes/v1/membershipOrg.ts index 255b836aa..2863c53fb 100644 --- a/backend/src/routes/v1/membershipOrg.ts +++ b/backend/src/routes/v1/membershipOrg.ts @@ -3,12 +3,13 @@ const router = express.Router(); import { param } from 'express-validator'; import { requireAuth, validateRequest } from '../../middleware'; import { membershipOrgController } from '../../controllers/v1'; +import { AUTH_MODE_JWT } from '../../variables'; router.post( // TODO '/membershipOrg/:membershipOrgId/change-role', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), param('membershipOrgId'), validateRequest, @@ -18,7 +19,7 @@ router.post( router.delete( '/:membershipOrgId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), param('membershipOrgId').exists().trim(), validateRequest, diff --git a/backend/src/routes/v1/organization.ts b/backend/src/routes/v1/organization.ts index 314f684ad..dded53f3a 100644 --- a/backend/src/routes/v1/organization.ts +++ b/backend/src/routes/v1/organization.ts @@ -6,13 +6,19 @@ import { requireOrganizationAuth, validateRequest } from '../../middleware'; -import { OWNER, ADMIN, MEMBER, ACCEPTED } from '../../variables'; +import { + OWNER, + ADMIN, + MEMBER, + ACCEPTED, + AUTH_MODE_JWT +} from '../../variables'; import { organizationController } from '../../controllers/v1'; router.get( // deprecated (moved to api/v2/users/me/organizations) '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), organizationController.getOrganizations ); @@ -20,7 +26,7 @@ router.get( // deprecated (moved to api/v2/users/me/organizations) router.post( // not used on frontend '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('organizationName').exists().trim().notEmpty(), validateRequest, @@ -30,7 +36,7 @@ router.post( // not used on frontend router.get( '/:organizationId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -44,7 +50,7 @@ router.get( router.get( // deprecated (moved to api/v2/organizations/:organizationId/memberships) '/:organizationId/users', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -58,7 +64,7 @@ router.get( // deprecated (moved to api/v2/organizations/:organizationId/members router.get( '/:organizationId/my-workspaces', // deprecated (moved to api/v2/organizations/:organizationId/workspaces) requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -72,7 +78,7 @@ router.get( router.patch( '/:organizationId/name', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -87,7 +93,7 @@ router.patch( router.get( '/:organizationId/incidentContactOrg', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -101,7 +107,7 @@ router.get( router.post( '/:organizationId/incidentContactOrg', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -116,7 +122,7 @@ router.post( router.delete( '/:organizationId/incidentContactOrg', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -131,7 +137,7 @@ router.delete( router.post( '/:organizationId/customer-portal-session', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -145,7 +151,7 @@ router.post( router.get( '/:organizationId/subscriptions', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -159,7 +165,7 @@ router.get( router.get( '/:organizationId/workspace-memberships', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], diff --git a/backend/src/routes/v1/password.ts b/backend/src/routes/v1/password.ts index bc353cf08..b04fa36af 100644 --- a/backend/src/routes/v1/password.ts +++ b/backend/src/routes/v1/password.ts @@ -4,11 +4,14 @@ import { body } from 'express-validator'; import { requireAuth, requireSignupAuth, validateRequest } from '../../middleware'; import { passwordController } from '../../controllers/v1'; import { passwordLimiter } from '../../helpers/rateLimiter'; +import { + AUTH_MODE_JWT +} from '../../variables'; router.post( '/srp1', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('clientPublicKey').exists().isString().trim().notEmpty(), validateRequest, @@ -19,7 +22,7 @@ router.post( '/change-password', passwordLimiter, requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('clientProof').exists().trim().notEmpty(), body('protectedKey').exists().isString().trim().notEmpty(), @@ -62,7 +65,7 @@ router.post( '/backup-private-key', passwordLimiter, requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('clientProof').exists().isString().trim().notEmpty(), body('encryptedPrivateKey').exists().isString().trim().notEmpty(), // (backup) private key encrypted under a strong key diff --git a/backend/src/routes/v1/secret.ts b/backend/src/routes/v1/secret.ts index cce105500..e55dfaf43 100644 --- a/backend/src/routes/v1/secret.ts +++ b/backend/src/routes/v1/secret.ts @@ -8,15 +8,22 @@ import { } from '../../middleware'; import { body, query, param } from 'express-validator'; import { secretController } from '../../controllers/v1'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT +} from '../../variables'; + +// note to devs: these endpoints will be deprecated in favor of v2 router.post( '/:workspaceId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), body('secrets').exists(), body('keys').exists(), @@ -30,10 +37,11 @@ router.post( router.get( '/:workspaceId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), query('environment').exists().trim(), query('channel'), diff --git a/backend/src/routes/v1/serviceToken.ts b/backend/src/routes/v1/serviceToken.ts index 18487ac3e..2b75e7cbf 100644 --- a/backend/src/routes/v1/serviceToken.ts +++ b/backend/src/routes/v1/serviceToken.ts @@ -7,7 +7,11 @@ import { validateRequest } from '../../middleware'; import { body } from 'express-validator'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT +} from '../../variables'; import { serviceTokenController } from '../../controllers/v1'; // note: deprecate service-token routes in favor of service-token data routes/structure @@ -21,11 +25,11 @@ router.get( router.post( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - location: 'body' + locationWorkspaceId: 'body' }), body('name').exists().trim().notEmpty(), body('workspaceId').exists().trim().notEmpty(), diff --git a/backend/src/routes/v1/user.ts b/backend/src/routes/v1/user.ts index e73a6e0f2..b9d88dfb1 100644 --- a/backend/src/routes/v1/user.ts +++ b/backend/src/routes/v1/user.ts @@ -2,11 +2,14 @@ import express from 'express'; const router = express.Router(); import { requireAuth } from '../../middleware'; import { userController } from '../../controllers/v1'; +import { + AUTH_MODE_JWT +} from '../../variables'; router.get( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), userController.getUser ); diff --git a/backend/src/routes/v1/userAction.ts b/backend/src/routes/v1/userAction.ts index b14ab3471..c8d21f918 100644 --- a/backend/src/routes/v1/userAction.ts +++ b/backend/src/routes/v1/userAction.ts @@ -3,12 +3,13 @@ const router = express.Router(); import { requireAuth, validateRequest } from '../../middleware'; import { body, query } from 'express-validator'; import { userActionController } from '../../controllers/v1'; +import { AUTH_MODE_JWT } from '../../variables'; // note: [userAction] will be deprecated in /v2 in favor of [action] router.post( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('action'), validateRequest, @@ -18,7 +19,7 @@ router.post( router.get( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), query('action'), validateRequest, diff --git a/backend/src/routes/v1/workspace.ts b/backend/src/routes/v1/workspace.ts index 801462662..431a2e4f9 100644 --- a/backend/src/routes/v1/workspace.ts +++ b/backend/src/routes/v1/workspace.ts @@ -6,16 +6,21 @@ import { requireWorkspaceAuth, validateRequest } from '../../middleware'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT +} from '../../variables'; import { workspaceController, membershipController } from '../../controllers/v1'; router.get( '/:workspaceId/keys', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -25,10 +30,11 @@ router.get( router.get( '/:workspaceId/users', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -38,7 +44,7 @@ router.get( router.get( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), workspaceController.getWorkspaces ); @@ -46,10 +52,11 @@ router.get( router.get( '/:workspaceId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -59,7 +66,7 @@ router.get( router.post( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('workspaceName').exists().trim().notEmpty(), body('organizationId').exists().trim().notEmpty(), @@ -70,10 +77,11 @@ router.post( router.delete( '/:workspaceId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN] + acceptedRoles: [ADMIN], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -83,10 +91,11 @@ router.delete( router.post( '/:workspaceId/name', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('name').exists().trim().notEmpty(), @@ -97,10 +106,11 @@ router.post( router.post( '/:workspaceId/invite-signup', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('email').exists().trim().notEmpty(), @@ -111,10 +121,11 @@ router.post( router.get( '/:workspaceId/integrations', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -124,10 +135,11 @@ router.get( router.get( '/:workspaceId/authorizations', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -137,10 +149,11 @@ router.get( router.get( '/:workspaceId/service-tokens', // deprecate requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, diff --git a/backend/src/routes/v2/apiKeyData.ts b/backend/src/routes/v2/apiKeyData.ts index 07bbcbc44..939bdbe1f 100644 --- a/backend/src/routes/v2/apiKeyData.ts +++ b/backend/src/routes/v2/apiKeyData.ts @@ -1,16 +1,19 @@ import express from 'express'; const router = express.Router(); +import { param, body } from 'express-validator'; import { requireAuth, validateRequest } from '../../middleware'; -import { param, body } from 'express-validator'; import { apiKeyDataController } from '../../controllers/v2'; +import { + AUTH_MODE_JWT +} from '../../variables'; router.get( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), apiKeyDataController.getAPIKeyData ); @@ -18,7 +21,7 @@ router.get( router.post( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('name').exists().trim(), body('expiresIn'), // measured in ms @@ -29,7 +32,7 @@ router.post( router.delete( '/:apiKeyDataId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), param('apiKeyDataId').exists().trim(), validateRequest, diff --git a/backend/src/routes/v2/environment.ts b/backend/src/routes/v2/environment.ts index 11afa4f2e..0eb4b4a20 100644 --- a/backend/src/routes/v2/environment.ts +++ b/backend/src/routes/v2/environment.ts @@ -7,15 +7,20 @@ import { requireWorkspaceAuth, validateRequest, } from '../../middleware'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT +} from '../../variables'; router.post( '/:workspaceId/environments', requireAuth({ - acceptedAuthModes: ['jwt'], + acceptedAuthModes: [AUTH_MODE_JWT], }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('environmentSlug').exists().trim(), @@ -27,10 +32,11 @@ router.post( router.put( '/:workspaceId/environments', requireAuth({ - acceptedAuthModes: ['jwt'], + acceptedAuthModes: [AUTH_MODE_JWT], }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('environmentSlug').exists().trim(), @@ -43,10 +49,11 @@ router.put( router.delete( '/:workspaceId/environments', requireAuth({ - acceptedAuthModes: ['jwt'], + acceptedAuthModes: [AUTH_MODE_JWT], }), requireWorkspaceAuth({ acceptedRoles: [ADMIN], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('environmentSlug').exists().trim(), @@ -57,14 +64,15 @@ router.delete( router.get( '/:workspaceId/environments', requireAuth({ - acceptedAuthModes: ['jwt'], + acceptedAuthModes: [AUTH_MODE_JWT], }), requireWorkspaceAuth({ acceptedRoles: [MEMBER, ADMIN], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, environmentController.getAllAccessibleEnvironmentsOfWorkspace ); -export default router; +export default router; \ 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..eb2cef8eb 100644 --- a/backend/src/routes/v2/organizations.ts +++ b/backend/src/routes/v2/organizations.ts @@ -6,8 +6,15 @@ import { requireMembershipOrgAuth, validateRequest } from '../../middleware'; -import { body, param, query } from 'express-validator'; -import { OWNER, ADMIN, MEMBER, ACCEPTED } from '../../variables'; +import { body, param } from 'express-validator'; +import { + OWNER, + ADMIN, + MEMBER, + ACCEPTED, + AUTH_MODE_JWT, + AUTH_MODE_API_KEY +} from '../../variables'; import { organizationsController } from '../../controllers/v2'; // TODO: /POST to create membership @@ -17,7 +24,7 @@ router.get( param('organizationId').exists().trim(), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], @@ -33,14 +40,15 @@ router.patch( body('role').exists().isString().trim().isIn([OWNER, ADMIN, MEMBER]), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN], acceptedStatuses: [ACCEPTED] }), requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN] + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] }), organizationsController.updateOrganizationMembership ); @@ -51,14 +59,15 @@ router.delete( param('membershipId').exists().trim(), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN], acceptedStatuses: [ACCEPTED] }), requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN] + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] }), organizationsController.deleteOrganizationMembership ); @@ -68,7 +77,7 @@ router.get( param('organizationId').exists().trim(), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN], @@ -77,4 +86,18 @@ router.get( organizationsController.getOrganizationWorkspaces ); +router.get( + '/:organizationId/service-accounts', + param('organizationId').exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_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/secret.ts b/backend/src/routes/v2/secret.ts index f1b61b47c..ff9764ced 100644 --- a/backend/src/routes/v2/secret.ts +++ b/backend/src/routes/v2/secret.ts @@ -6,7 +6,12 @@ import { validateRequest } from '../../middleware'; import { body, param, query } from 'express-validator'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_TOKEN +} from '../../variables'; import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret'; import { secretController } from '../../controllers/v2'; @@ -17,10 +22,11 @@ const router = express.Router(); router.post( '/batch-create/workspace/:workspaceId/environment/:environment', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().isMongoId().trim(), param('environment').exists().trim(), @@ -33,10 +39,11 @@ router.post( router.post( '/workspace/:workspaceId/environment/:environment', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().isMongoId().trim(), param('environment').exists().trim(), @@ -51,10 +58,11 @@ router.get( param('workspaceId').exists().trim(), query("environment").exists(), requireAuth({ - acceptedAuthModes: ['jwt', 'serviceToken'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_SERVICE_TOKEN] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), query('channel'), validateRequest, @@ -64,7 +72,7 @@ router.get( router.get( '/:secretId', requireAuth({ - acceptedAuthModes: ['jwt', 'serviceToken'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_SERVICE_TOKEN] }), requireSecretAuth({ acceptedRoles: [ADMIN, MEMBER] @@ -76,13 +84,14 @@ router.get( router.delete( '/batch/workspace/:workspaceId/environment/:environmentName', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), param('workspaceId').exists().isMongoId().trim(), param('environmentName').exists().trim(), body('secretIds').exists().isArray().custom(array => array.length > 0), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), validateRequest, secretController.deleteSecrets @@ -91,7 +100,7 @@ router.delete( router.delete( '/:secretId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireSecretAuth({ acceptedRoles: [ADMIN, MEMBER] @@ -104,29 +113,30 @@ router.delete( router.patch( '/batch-modify/workspace/:workspaceId/environment/:environmentName', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('secrets').exists().isArray().custom((secrets: ModifySecretRequestBody[]) => secrets.length > 0), param('workspaceId').exists().isMongoId().trim(), param('environmentName').exists().trim(), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), validateRequest, secretController.updateSecrets ); - router.patch( '/workspace/:workspaceId/environment/:environmentName', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), body('secret').isObject(), param('workspaceId').exists().isMongoId().trim(), param('environmentName').exists().trim(), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), validateRequest, secretController.updateSecret diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 983a8e54f..792491557 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -8,12 +8,18 @@ import { } from '../../middleware'; import { query, body } from 'express-validator'; import { secretsController } from '../../controllers/v2'; -import { validateSecrets } from '../../helpers/secret'; +import { validateClientForSecrets } from '../../helpers/secrets'; import { ADMIN, MEMBER, SECRET_PERSONAL, - SECRET_SHARED + SECRET_SHARED, + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY } from '../../variables'; import { BatchSecretRequest @@ -22,12 +28,11 @@ import { router.post( '/batch', requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey', 'serviceToken'], - requiredServiceTokenPermissions: ['read', 'write'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - location: 'body' + locationWorkspaceId: 'body' }), body('workspaceId').exists().isString().trim(), body('environment').exists().isString().trim(), @@ -40,12 +45,11 @@ router.post( .filter((secretId) => secretId !== undefined) if (secretIds.length > 0) { - const relevantSecrets = await validateSecrets({ - userId: req.user._id.toString(), - secretIds + req.secrets = await validateClientForSecrets({ + authData: req.authData, + secretIds, + requiredPermissions: [] }); - - req.secrets = relevantSecrets; } } return true; @@ -100,12 +104,13 @@ router.post( }), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey', 'serviceToken'], - requiredServiceTokenPermissions: ['write'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - location: 'body' + locationWorkspaceId: 'body', + locationEnvironment: 'body', + requiredPermissions: [PERMISSION_WRITE_SECRETS] }), secretsController.createSecrets ); @@ -117,12 +122,13 @@ router.get( query('tagSlugs'), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey', 'serviceToken'], - requiredServiceTokenPermissions: ['read'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - location: 'query' + locationWorkspaceId: 'query', + locationEnvironment: 'query', + requiredPermissions: [PERMISSION_READ_SECRETS] }), secretsController.getSecrets ); @@ -157,11 +163,11 @@ router.patch( }), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey', 'serviceToken'], - requiredServiceTokenPermissions: ['write'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireSecretsAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + requiredPermissions: [PERMISSION_WRITE_SECRETS] }), secretsController.updateSecrets ); @@ -186,14 +192,13 @@ router.delete( .isEmpty(), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey', 'serviceToken'], - requiredServiceTokenPermissions: ['write'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY, AUTH_MODE_SERVICE_TOKEN] }), requireSecretsAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + requiredPermissions: [PERMISSION_WRITE_SECRETS] }), secretsController.deleteSecrets ); -export default router; - +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..fef0c87e9 --- /dev/null +++ b/backend/src/routes/v2/serviceAccounts.ts @@ -0,0 +1,159 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + requireOrganizationAuth, + requireWorkspaceAuth, + requireServiceAccountAuth, + requireServiceAccountWorkspacePermissionAuth, + validateRequest +} from '../../middleware'; +import { param, query, body } from 'express-validator'; +import { + OWNER, + ADMIN, + MEMBER, + ACCEPTED, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT +} from '../../variables'; +import { serviceAccountsController } from '../../controllers/v2'; + +router.get( // TODO: check + '/me', + requireAuth({ + acceptedAuthModes: [AUTH_MODE_SERVICE_ACCOUNT] + }), + serviceAccountsController.getCurrentServiceAccount +); + +router.get( + '/:serviceAccountId', + param('serviceAccountId').exists().isString().trim(), + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.getServiceAccountById +); + +router.post( + '/', + body('organizationId').exists().isString().trim(), + body('name').exists().isString().trim(), + body('publicKey').exists().isString().trim(), + body('expiresIn').isNumeric(), // measured in ms + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED], + location: 'body' + }), + serviceAccountsController.createServiceAccount +); + +router.patch( + '/:serviceAccountId/name', + param('serviceAccountId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.changeServiceAccountName +); + +router.delete( + '/:serviceAccountId', + param('serviceAccountId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.deleteServiceAccount +); + +router.get( + '/:serviceAccountId/permissions/workspace', + param('serviceAccountId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.getServiceAccountWorkspacePermissions +); + +router.post( + '/:serviceAccountId/permissions/workspace', + param('serviceAccountId').exists().isString().trim(), + body('workspaceId').exists().isString().notEmpty(), + body('environment').exists().isString().notEmpty(), + body('read').isBoolean().optional(), + body('write').isBoolean().optional(), + body('encryptedKey').exists().isString().notEmpty(), + body('nonce').exists().isString().notEmpty(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'body' + }), + serviceAccountsController.addServiceAccountWorkspacePermission +); + +router.delete( + '/:serviceAccountId/permissions/workspace/:serviceAccountWorkspacePermissionId', + param('serviceAccountId').exists().isString().trim(), + param('serviceAccountWorkspacePermissionId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireServiceAccountWorkspacePermissionAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.deleteServiceAccountWorkspacePermission +); + +router.get( + '/:serviceAccountId/keys', + query('workspaceId').optional().isString(), + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_SERVICE_ACCOUNT] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.getServiceAccountKeys +); + +export default router; \ No newline at end of file diff --git a/backend/src/routes/v2/serviceTokenData.ts b/backend/src/routes/v2/serviceTokenData.ts index 11e8b1c71..075d0d392 100644 --- a/backend/src/routes/v2/serviceTokenData.ts +++ b/backend/src/routes/v2/serviceTokenData.ts @@ -10,13 +10,17 @@ import { param, body } from 'express-validator'; import { ADMIN, MEMBER, + PERMISSION_WRITE_SECRETS, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN } from '../../variables'; import { serviceTokenDataController } from '../../controllers/v2'; router.get( '/', requireAuth({ - acceptedAuthModes: ['serviceToken'] + acceptedAuthModes: [AUTH_MODE_SERVICE_TOKEN] }), serviceTokenDataController.getServiceTokenData ); @@ -24,11 +28,13 @@ router.get( router.post( '/', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_SERVICE_ACCOUNT] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - location: 'body' + locationWorkspaceId: 'body', + locationEnvironment: 'body', + requiredPermissions: [PERMISSION_WRITE_SECRETS] }), body('name').exists().isString().trim(), body('workspaceId').exists().isString().trim(), @@ -53,7 +59,7 @@ router.post( router.delete( '/:serviceTokenDataId', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireServiceTokenDataAuth({ acceptedRoles: [ADMIN, MEMBER] diff --git a/backend/src/routes/v2/tags.ts b/backend/src/routes/v2/tags.ts index d78e1e0f1..c9a11c1bc 100644 --- a/backend/src/routes/v2/tags.ts +++ b/backend/src/routes/v2/tags.ts @@ -5,17 +5,22 @@ import { tagController } from '../../controllers/v2'; import { requireAuth, requireWorkspaceAuth, - validateRequest, + validateRequest } from '../../middleware'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT +} from '../../variables'; router.get( '/:workspaceId/tags', requireAuth({ - acceptedAuthModes: ['jwt'], + acceptedAuthModes: [AUTH_MODE_JWT], }), requireWorkspaceAuth({ acceptedRoles: [MEMBER, ADMIN], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -25,7 +30,7 @@ router.get( router.delete( '/tags/:tagId', requireAuth({ - acceptedAuthModes: ['jwt'], + acceptedAuthModes: [AUTH_MODE_JWT], }), param('tagId').exists().trim(), validateRequest, @@ -35,10 +40,11 @@ router.delete( router.post( '/:workspaceId/tags', requireAuth({ - acceptedAuthModes: ['jwt'], + acceptedAuthModes: [AUTH_MODE_JWT], }), requireWorkspaceAuth({ acceptedRoles: [MEMBER, ADMIN], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('name').exists().trim(), diff --git a/backend/src/routes/v2/users.ts b/backend/src/routes/v2/users.ts index bdf0978e1..63ae5eee9 100644 --- a/backend/src/routes/v2/users.ts +++ b/backend/src/routes/v2/users.ts @@ -6,11 +6,15 @@ import { } from '../../middleware'; import { body } from 'express-validator'; import { usersController } from '../../controllers/v2'; +import { + AUTH_MODE_JWT, + AUTH_MODE_API_KEY +} from '../../variables'; router.get( '/me', requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), usersController.getMe ); @@ -18,7 +22,7 @@ router.get( router.patch( '/me/mfa', requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), body('isMfaEnabled').exists().isBoolean(), validateRequest, @@ -28,7 +32,7 @@ router.patch( router.get( '/me/organizations', requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), usersController.getMyOrganizations ); diff --git a/backend/src/routes/v2/workspace.ts b/backend/src/routes/v2/workspace.ts index 6183c9e66..d2180624b 100644 --- a/backend/src/routes/v2/workspace.ts +++ b/backend/src/routes/v2/workspace.ts @@ -7,16 +7,23 @@ import { requireWorkspaceAuth, validateRequest } from '../../middleware'; -import { ADMIN, MEMBER } from '../../variables'; +import { + ADMIN, + MEMBER, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../../variables'; import { workspaceController } from '../../controllers/v2'; router.post( '/:workspaceId/secrets', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), body('secrets').exists(), body('keys').exists(), @@ -30,10 +37,11 @@ router.post( router.get( '/:workspaceId/secrets', requireAuth({ - acceptedAuthModes: ['jwt', 'serviceToken'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_SERVICE_TOKEN] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), query('environment').exists().trim(), query('channel'), @@ -45,10 +53,11 @@ router.get( router.get( '/:workspaceId/encrypted-key', requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, @@ -58,27 +67,27 @@ router.get( router.get( '/:workspaceId/service-token-data', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), validateRequest, workspaceController.getWorkspaceServiceTokenData ); -// TODO: /POST to create membership and re-route inviting user to workspace there - router.get( // new - TODO: rewire dashboard to this route '/:workspaceId/memberships', param('workspaceId').exists().trim(), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), workspaceController.getWorkspaceMemberships ); @@ -90,10 +99,11 @@ router.patch( // TODO - rewire dashboard to this route body('role').exists().isString().trim().isIn([ADMIN, MEMBER]), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN], + locationWorkspaceId: 'params' }), requireMembershipAuth({ acceptedRoles: [ADMIN] @@ -107,10 +117,11 @@ router.delete( // TODO - rewire dashboard to this route param('membershipId').exists().trim(), validateRequest, requireAuth({ - acceptedAuthModes: ['jwt', 'apiKey'] + acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN], + locationWorkspaceId: 'params' }), requireMembershipAuth({ acceptedRoles: [ADMIN] @@ -121,10 +132,11 @@ router.delete( // TODO - rewire dashboard to this route router.patch( '/:workspaceId/auto-capitalization', requireAuth({ - acceptedAuthModes: ['jwt'] + acceptedAuthModes: [AUTH_MODE_JWT] }), requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER] + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'params' }), param('workspaceId').exists().trim(), body('autoCapitalization').exists().trim().notEmpty(), diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts new file mode 100644 index 000000000..b415139a9 --- /dev/null +++ b/backend/src/routes/v3/secrets.ts @@ -0,0 +1,8 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, validateRequest +} from '../../middleware'; +import { body } from 'express-validator'; + +export default router; \ No newline at end of file diff --git a/backend/src/services/PostHogClient.ts b/backend/src/services/PostHogClient.ts deleted file mode 100644 index 15ccf0919..000000000 --- a/backend/src/services/PostHogClient.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { PostHog } from 'posthog-node'; -import { getLogger } from '../utils/logger'; -import { - getNodeEnv, - getTelemetryEnabled, - getPostHogProjectApiKey, - getPostHogHost -} from '../config'; - -/** - * Logs telemetry enable/disable notice. - */ -const logTelemetryMessage = () => { - if(!getTelemetryEnabled()){ - getLogger("backend-main").info([ - "", - "To improve, Infisical collects telemetry data about general usage.", - "This helps us understand how the product is doing and guide our product development to create the best possible platform; it also helps us demonstrate growth as we support Infisical as open-source software.", - "To opt into telemetry, you can set `TELEMETRY_ENABLED=true` within the environment variables.", - ].join('\n')) - } -} - -/** - * Return an instance of the PostHog client initialized. - * @returns - */ -const getPostHogClient = () => { - let postHogClient: any; - if (getNodeEnv() === 'production' && getTelemetryEnabled()) { - // case: enable opt-out telemetry in production - postHogClient = new PostHog(getPostHogProjectApiKey(), { - host: getPostHogHost() - }); - } - - return postHogClient; -} - -export { - logTelemetryMessage, - getPostHogClient -} - diff --git a/backend/src/services/TelemetryService.ts b/backend/src/services/TelemetryService.ts new file mode 100644 index 000000000..0566439b4 --- /dev/null +++ b/backend/src/services/TelemetryService.ts @@ -0,0 +1,85 @@ +import { PostHog } from 'posthog-node'; +import { getLogger } from '../utils/logger'; +import { + getNodeEnv, + getTelemetryEnabled, + getPostHogProjectApiKey, + getPostHogHost +} from '../config'; +import { + IUser, + IServiceAccount, + IServiceTokenData +} from '../models'; +import { + BadRequestError +} from '../utils/errors'; + +class Telemetry { + /** + * Logs telemetry enable/disable notice. + */ + static logTelemetryMessage = () => { + if(!getTelemetryEnabled()){ + getLogger("backend-main").info([ + "", + "To improve, Infisical collects telemetry data about general usage.", + "This helps us understand how the product is doing and guide our product development to create the best possible platform; it also helps us demonstrate growth as we support Infisical as open-source software.", + "To opt into telemetry, you can set `TELEMETRY_ENABLED=true` within the environment variables.", + ].join('\n')) + } + } + + /** + * Return an instance of the PostHog client initialized. + * @returns + */ + static getPostHogClient = () => { + let postHogClient: any; + if (getNodeEnv() === 'production' && getTelemetryEnabled()) { + // case: enable opt-out telemetry in production + postHogClient = new PostHog(getPostHogProjectApiKey(), { + host: getPostHogHost() + }); + } + + return postHogClient; + } + + /** + * Return a distinct id for client to be used for logging telemetry + */ + static getDistinctId = ({ + user, + serviceAccount, + serviceTokenData + }: { + user?: IUser; + serviceAccount?: IServiceAccount; + serviceTokenData?: IServiceTokenData; + }) => { + let distinctId = ''; + + if (user) { + distinctId = user.email; + } + + if (serviceAccount) { + distinctId = `sa.${serviceAccount._id}`; + } + + if (serviceTokenData) { + distinctId = `st.${serviceTokenData._id}`; + } + + if (distinctId === '') { + throw BadRequestError({ + message: 'Failed to obtain distinct id for logging telemetry' + }); + } + + return distinctId; + } +} + +export default Telemetry; \ No newline at end of file diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index d98b70718..f93f45f58 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -1,13 +1,15 @@ import DatabaseService from './DatabaseService'; -import { logTelemetryMessage, getPostHogClient } from './PostHogClient'; +// import { logTelemetryMessage, getPostHogClient } from './TelemetryService'; +import TelemetryService from './TelemetryService'; import BotService from './BotService'; import EventService from './EventService'; import IntegrationService from './IntegrationService'; import TokenService from './TokenService'; export { - logTelemetryMessage, - getPostHogClient, + TelemetryService, + // logTelemetryMessage, + // getPostHogClient, DatabaseService, BotService, EventService, 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/types/express/index.d.ts b/backend/src/types/express/index.d.ts index acee877bd..bff5e96c3 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -1,5 +1,10 @@ import * as express from 'express'; -import { ISecret } from '../../models'; +import { + IUser, + IServiceAccount, + IServiceTokenData, + ISecret +} from '../../models'; // TODO: fix (any) types declare global { @@ -19,10 +24,18 @@ declare global { secrets: any; secretSnapshot: any; serviceToken: any; + serviceAccount: any; accessToken: any; serviceTokenData: any; apiKeyData: any; query?: any; + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + requestData: { + [key: string]: string + }; } } } 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/authentication.ts b/backend/src/variables/authentication.ts new file mode 100644 index 000000000..2fe1f4fc6 --- /dev/null +++ b/backend/src/variables/authentication.ts @@ -0,0 +1,11 @@ +const AUTH_MODE_JWT = 'jwt'; +const AUTH_MODE_SERVICE_ACCOUNT = 'serviceAccount'; +const AUTH_MODE_SERVICE_TOKEN = 'serviceToken'; +const AUTH_MODE_API_KEY = 'apiKey'; // TODO: deprecate + +export { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} \ No newline at end of file diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index b71044cba..3029b265f 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -15,6 +15,7 @@ import { INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, @@ -31,6 +32,7 @@ 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, @@ -63,6 +65,16 @@ import { TOKEN_EMAIL_ORG_INVITATION, TOKEN_EMAIL_PASSWORD_RESET } from './token'; +import { + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS +} from './permission'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from './authentication'; export { OWNER, @@ -86,6 +98,7 @@ export { INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, @@ -102,6 +115,7 @@ export { 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, @@ -113,6 +127,8 @@ export { ACTION_UPDATE_SECRETS, ACTION_DELETE_SECRETS, ACTION_READ_SECRETS, + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS, getIntegrationOptions, SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN, @@ -124,5 +140,9 @@ export { TOKEN_EMAIL_CONFIRMATION, TOKEN_EMAIL_MFA, TOKEN_EMAIL_ORG_INVITATION, - TOKEN_EMAIL_PASSWORD_RESET + TOKEN_EMAIL_PASSWORD_RESET, + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY }; diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 52bfcc614..3c25c79da 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -17,6 +17,7 @@ const INTEGRATION_NETLIFY = "netlify"; const INTEGRATION_GITHUB = "github"; const INTEGRATION_GITLAB = "gitlab"; const INTEGRATION_RENDER = "render"; +const INTEGRATION_RAILWAY = "railway"; const INTEGRATION_FLYIO = "flyio"; const INTEGRATION_CIRCLECI = "circleci"; const INTEGRATION_TRAVISCI = "travisci"; @@ -52,6 +53,7 @@ const INTEGRATION_GITLAB_API_URL = "https://gitlab.com/api"; const INTEGRATION_VERCEL_API_URL = "https://api.vercel.com"; const INTEGRATION_NETLIFY_API_URL = "https://api.netlify.com"; const INTEGRATION_RENDER_API_URL = "https://api.render.com"; +const INTEGRATION_RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2"; const INTEGRATION_FLYIO_API_URL = "https://api.fly.io/graphql"; const INTEGRATION_CIRCLECI_API_URL = "https://circleci.com/api"; const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; @@ -104,6 +106,15 @@ const getIntegrationOptions = () => { clientId: '', docsLink: '' }, + { + name: 'Railway', + slug: 'railway', + image: 'Railway.png', + isAvailable: true, + type: 'pat', + clientId: '', + docsLink: '' + }, { name: 'Fly.io', slug: 'flyio', @@ -192,6 +203,7 @@ export { INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_RENDER, + INTEGRATION_RAILWAY, INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, @@ -208,6 +220,7 @@ export { 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, diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts index 91af2ff25..80c7102c1 100644 --- a/backend/src/variables/organization.ts +++ b/backend/src/variables/organization.ts @@ -6,11 +6,7 @@ const MEMBER = "member"; // membership statuses const INVITED = "invited"; -// membership permissions ability -const ABILITY_READ = "read"; -const ABILITY_WRITE = "write"; - // -- organization const ACCEPTED = "accepted"; -export { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED, ABILITY_READ, ABILITY_WRITE }; +export { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED }; diff --git a/backend/src/variables/permission.ts b/backend/src/variables/permission.ts new file mode 100644 index 000000000..769344d7f --- /dev/null +++ b/backend/src/variables/permission.ts @@ -0,0 +1,7 @@ +const PERMISSION_READ_SECRETS = 'read'; +const PERMISSION_WRITE_SECRETS = 'write'; + +export { + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS +} \ No newline at end of file diff --git a/docs/images/integrations-railway-authorization.png b/docs/images/integrations-railway-authorization.png new file mode 100644 index 000000000..8608ea6d1 Binary files /dev/null and b/docs/images/integrations-railway-authorization.png differ diff --git a/docs/images/integrations-railway-create.png b/docs/images/integrations-railway-create.png new file mode 100644 index 000000000..548c06c5e Binary files /dev/null and b/docs/images/integrations-railway-create.png differ diff --git a/docs/images/integrations-railway-dashboard.png b/docs/images/integrations-railway-dashboard.png new file mode 100644 index 000000000..0d6b66754 Binary files /dev/null and b/docs/images/integrations-railway-dashboard.png differ diff --git a/docs/images/integrations-railway-token.png b/docs/images/integrations-railway-token.png new file mode 100644 index 000000000..d414289e9 Binary files /dev/null and b/docs/images/integrations-railway-token.png differ diff --git a/docs/images/integrations-railway.png b/docs/images/integrations-railway.png new file mode 100644 index 000000000..0bde672d2 Binary files /dev/null and b/docs/images/integrations-railway.png differ diff --git a/docs/images/integrations.png b/docs/images/integrations.png index 44a8269dd..234a842b3 100644 Binary files a/docs/images/integrations.png and b/docs/images/integrations.png differ diff --git a/docs/integrations/cloud/railway.mdx b/docs/integrations/cloud/railway.mdx new file mode 100644 index 000000000..5ebf8fc1d --- /dev/null +++ b/docs/integrations/cloud/railway.mdx @@ -0,0 +1,54 @@ +--- +title: "Railway" +description: "How to automatically sync secrets from Infisical into your Railway projects and services" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + +## Navigate to your project's integrations tab + +![integrations](../../images/integrations.png) + +## Enter your Railway API Token + +Obtain a Railway API Token in your Railway [Account Settings > Tokens](https://railway.app/account/tokens). + +![integrations railway dashboard](../../images/integrations-railway-dashboard.png) +![integrations railway token](../../images/integrations-railway-token.png) + + + If this is your first time creating a Railway API token, then you'll be prompted to join + Railway's Private Boarding Beta program on the Railway Account Settings > Tokens page. + + Note that Railway project tokens will not work for this integration since they don't work with + Railway's Public API. + + +Press on the Railway tile and input your Railway API Key to grant Infisical access to your Railway account. + +![integrations railway authorization](../../images/integrations-railway-authorization.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + +## Start integration + +Select which Infisical environment secrets you want to sync to which Railway project and environment (and optionally service). Lastly, press create integration to start syncing secrets to Railway. + +![integrations create railway](../../images/integrations-railway-create.png) + + + Infisical integrates with both Railway's [shared variables](https://blog.railway.app/p/shared-variables-release) at the project environment level as well as service variables at the service level. + + To sync secrets to a specific service in a project, you can select a service from the Railway Service dropdown; otherwise, leaving it empty will sync secrets to the shared variables of that project. + + +![integrations railway](../../images/integrations-railway.png) + diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 15e5698c1..e6e2622cd 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -18,6 +18,7 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi | [Vercel](/integrations/cloud/vercel) | Cloud | Available | | [Netlify](/integrations/cloud/netlify) | Cloud | Available | | [Render](/integrations/cloud/render) | Cloud | Available | +| [Railway](/integrations/cloud/railway) | Cloud | Available | | [Fly.io](/integrations/cloud/flyio) | Cloud | Available | | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | | [AWS Secret Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | diff --git a/docs/mint.json b/docs/mint.json index 48dc1805c..165ce6862 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -149,6 +149,7 @@ "integrations/cloud/vercel", "integrations/cloud/netlify", "integrations/cloud/render", + "integrations/cloud/railway", "integrations/cloud/flyio", "integrations/cloud/azure-key-vault", "integrations/cicd/githubactions", diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 8a69465ac..f5e5e5016 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -12,6 +12,7 @@ const integrationSlugNameMapping: Mapping = { 'github': 'GitHub', 'gitlab': 'GitLab', 'render': 'Render', + 'railway': 'Railway', 'flyio': 'Fly.io', 'circleci': 'CircleCI', 'travisci': 'TravisCI' diff --git a/frontend/public/images/integrations/Railway.png b/frontend/public/images/integrations/Railway.png new file mode 100644 index 000000000..61d53cf62 Binary files /dev/null and b/frontend/public/images/integrations/Railway.png differ 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/integrations/Integration.tsx b/frontend/src/components/integrations/Integration.tsx index e01c31e2b..2467566c4 100644 --- a/frontend/src/components/integrations/Integration.tsx +++ b/frontend/src/components/integrations/Integration.tsx @@ -45,6 +45,7 @@ type Props = { handleDeleteIntegration: (args: { integration: Integration }) => void; }; +// TODO: refactor const IntegrationTile = ({ integration, integrations, @@ -55,7 +56,6 @@ const IntegrationTile = ({ handleDeleteIntegration }: Props) => { - // set initial environment. This find will only execute when component is mounting const [integrationEnvironment, setIntegrationEnvironment] = useState( environments.find(({ slug }) => slug === integration?.environment) || { name: '', @@ -176,6 +176,21 @@ const IntegrationTile = ({ /> ); + case 'railway': + return ( +
+
ENVIRONMENT
+ +
+ ); default: return
; } 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 75b69a109..830306d5a 100644 --- a/frontend/src/components/utilities/cryptography/crypto.ts +++ b/frontend/src/components/utilities/cryptography/crypto.ts @@ -5,12 +5,52 @@ 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; privateKey: string; }; +/** + * Verify that private key [privateKey] is the one that corresponds to + * the public key [publicKey] + * @param {Object} + * @param {String} - base64-encoded Nacl private key + * @param {String} - base64-encoded Nacl public key + */ +const verifyPrivateKey = ({ + privateKey, + publicKey +}: { + privateKey: string; + publicKey: string; +}) => { + const derivedPublicKey = nacl.util.encodeBase64( + nacl.box.keyPair.fromSecretKey( + nacl.util.decodeBase64(privateKey) + ).publicKey + ); + + if (derivedPublicKey !== publicKey) { + throw new Error('Failed to verify private key'); + } +} + /** * Derive a key from password [password] and salt [salt] using Argon2id * @param {Object} obj @@ -189,6 +229,7 @@ export { decryptAssymmetric, decryptSymmetric, deriveArgonKey, - encryptAssymmetric, - encryptSymmetric -}; + encryptAssymmetric, + encryptSymmetric, + generateKeyPair, + verifyPrivateKey}; diff --git a/frontend/src/ee/components/ActivityTable.tsx b/frontend/src/ee/components/ActivityTable.tsx index 1867128d4..eec8a21fc 100644 --- a/frontend/src/ee/components/ActivityTable.tsx +++ b/frontend/src/ee/components/ActivityTable.tsx @@ -21,6 +21,12 @@ interface LogData { createdAt: string; ipAddress: string; user: string; + serviceAccount: { + name: string; + }; + serviceTokenData: { + name: string; + }; payload: PayloadProps[]; } @@ -41,6 +47,14 @@ const ActivityLogsRow = ({ const [payloadOpened, setPayloadOpened] = useState(false); const { t } = useTranslation(); + const renderUser = () => { + if (row?.user) return `User: ${row.user}`; + if (row?.serviceAccount) return `Service Account: ${row.serviceAccount.name}`; + if (row?.serviceTokenData.name) return `Service Token: ${row.serviceTokenData.name}`; + + return ''; + } + return ( <> @@ -64,7 +78,7 @@ const ActivityLogsRow = ({ ) .join(' and ')} - {row.user} + {renderUser()} {row.channel} {timeSince(new Date(row.createdAt))} diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 66d1a3933..89d947068 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -4,6 +4,7 @@ export * from './keys'; export * from './organization'; export * from './secrets'; export * from './secretSnapshots'; +export * from './serviceAccounts'; export * from './serviceTokens'; export * from './subscriptions'; export * from './tags'; diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index 443316c19..367534305 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -1,6 +1,7 @@ export { useGetIntegrationAuthApps, useGetIntegrationAuthById, + useGetIntegrationAuthRailwayEnvironments, + useGetIntegrationAuthRailwayServices, useGetIntegrationAuthTeams, - useGetIntegrationAuthVercelBranches -} 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 167f39a7d..02a8b6c51 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -4,7 +4,9 @@ import { apiRequest } from "@app/config/request"; import { App, + Environment, IntegrationAuth, + Service, Team } from './types'; @@ -18,7 +20,21 @@ 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, + getIntegrationAuthRailwayServices: ({ + integrationAuthId, + appId + }: { + integrationAuthId: string; + appId: string; + }) => [{ integrationAuthId, appId }, 'integrationAuthRailwayServices'] as const } const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -62,6 +78,38 @@ 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; +} + +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), @@ -114,3 +162,43 @@ export const useGetIntegrationAuthVercelBranches = ({ enabled: true }); } + +export const useGetIntegrationAuthRailwayEnvironments = ({ + integrationAuthId, + appId +}: { + integrationAuthId: string; + appId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthRailwayEnvironments({ + integrationAuthId, + appId, + }), + queryFn: () => fetchIntegrationAuthRailwayEnvironments({ + integrationAuthId, + appId, + }), + 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 4ba19c192..7ea8799a5 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -15,4 +15,14 @@ export type App = { export type Team = { name: string; teamId: string; +} + +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/hooks/api/serviceAccounts/index.tsx b/frontend/src/hooks/api/serviceAccounts/index.tsx new file mode 100644 index 000000000..a063d40dd --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/index.tsx @@ -0,0 +1,10 @@ +export { + useCreateServiceAccount, + useCreateServiceAccountProjectLevelPermission, + useDeleteServiceAccount, + useDeleteServiceAccountProjectLevelPermission, + 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..ec7888547 --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/queries.tsx @@ -0,0 +1,137 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiRequest } from '@app/config/request'; + +import { + CreateServiceAccountDTO, + CreateServiceAccountRes, + CreateServiceAccountWorkspacePermissionDTO, + DeleteServiceAccountWorkspacePermissionDTO, + RenameServiceAccountDTO, + ServiceAccount, + ServiceAccountWorkspacePermission +} 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: { serviceAccountWorkspacePermissions } } = await apiRequest.get<{ serviceAccountWorkspacePermissions: ServiceAccountWorkspacePermission[] }>( + `/api/v2/service-accounts/${serviceAccountId}/permissions/workspace` + ); + + return serviceAccountWorkspacePermissions; +} + +export const useGetServiceAccountProjectLevelPermissions = (serviceAccountId: string) => { + return useQuery({ + queryKey: serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccountId), + queryFn: () => fetchServiceAccountProjectLevelPermissions(serviceAccountId), + enabled: true + }); +} + +export const useCreateServiceAccountProjectLevelPermission = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body) => { + const { data: { serviceAccountWorkspacePermission } } = await apiRequest.post(`/api/v2/service-accounts/${body.serviceAccountId}/permissions/workspace`, body); + return serviceAccountWorkspacePermission; + }, + onSuccess: ({ serviceAccount }) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount)); + } + }); +} + +export const useDeleteServiceAccountProjectLevelPermission = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ serviceAccountId, serviceAccountWorkspacePermissionId }) => { + const { data: { serviceAccountWorkspacePermission} } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}/permissions/workspace/${serviceAccountWorkspacePermissionId}`); + return serviceAccountWorkspacePermission; + }, + onSuccess: (serviceAccountWorkspacePermission) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccountWorkspacePermission.serviceAccount)); + } + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceAccounts/types.ts b/frontend/src/hooks/api/serviceAccounts/types.ts new file mode 100644 index 000000000..f8865729b --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/types.ts @@ -0,0 +1,51 @@ +import { Workspace } from '../workspace/types'; + +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 RenameServiceAccountDTO = { + serviceAccountId: string; + name: string; +} + +export type ServiceAccountWorkspacePermission = { + _id: string; + serviceAccount: string; + workspace: Workspace; + environment: string; + read: boolean; + write: boolean; +} + +export type CreateServiceAccountWorkspacePermissionDTO = { + serviceAccountId: string; + workspaceId: string; + environment: string; + read: boolean; + write: boolean; + encryptedKey: string; + nonce: string; +} + +export type DeleteServiceAccountWorkspacePermissionDTO = { + serviceAccountId: string; + serviceAccountWorkspacePermissionId: string; +} \ No newline at end of file diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 46b339287..93ed5b860 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,38 @@ 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 lastPathSegments = router.asPath.split('/').pop(); + if (lastPathSegments !== undefined) { + [intendedWorkspaceId] = lastPathSegments.split('?'); + } + + // 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 +221,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/activity/[id].tsx b/frontend/src/pages/activity/[id].tsx index 8e16fccd7..6ad98d031 100644 --- a/frontend/src/pages/activity/[id].tsx +++ b/frontend/src/pages/activity/[id].tsx @@ -20,6 +20,12 @@ interface LogData { user: { email: string; }; + serviceAccount?: { + string: string; + }, + serviceTokenData?: { + name: string; + } actions: { _id: string; name: string; @@ -69,13 +75,16 @@ export default function Activity() { userId: '', actionNames: eventChosen }); + setLogsData( tempLogsData.map((log: LogData) => ({ _id: log._id, channel: log.channel, createdAt: log.createdAt, ipAddress: log.ipAddress, - user: log.user.email, + user: log?.user?.email, + serviceAccount: log?.serviceAccount, + serviceTokenData: log?.serviceTokenData, payload: log.actions.map((action) => ({ _id: action._id, name: action.name, @@ -106,7 +115,9 @@ export default function Activity() { channel: log.channel, createdAt: log.createdAt, ipAddress: log.ipAddress, - user: log.user.email, + user: log?.user?.email, + serviceAccount: log?.serviceAccount, + serviceTokenData: log?.serviceTokenData, payload: log.actions.map((action) => ({ _id: action._id, name: action.name, diff --git a/frontend/src/pages/api/integrations/createIntegration.ts b/frontend/src/pages/api/integrations/createIntegration.ts index d861223f1..e3e7c010a 100644 --- a/frontend/src/pages/api/integrations/createIntegration.ts +++ b/frontend/src/pages/api/integrations/createIntegration.ts @@ -7,6 +7,9 @@ interface Props { appId: string | null; sourceEnvironment: string; targetEnvironment: string | null; + targetEnvironmentId: string | null; + targetService: string | null; + targetServiceId: string | null; owner: string | null; path: string | null; region: string | null; @@ -24,6 +27,9 @@ const createIntegration = ({ appId, sourceEnvironment, targetEnvironment, + targetEnvironmentId, + targetService, + targetServiceId, owner, path, region @@ -40,6 +46,9 @@ const createIntegration = ({ appId, sourceEnvironment, targetEnvironment, + targetEnvironmentId, + targetService, + targetServiceId, owner, path, region diff --git a/frontend/src/pages/api/workspace/getLatestFileKey.ts b/frontend/src/pages/api/workspace/getLatestFileKey.ts index 576fcf943..f649987db 100644 --- a/frontend/src/pages/api/workspace/getLatestFileKey.ts +++ b/frontend/src/pages/api/workspace/getLatestFileKey.ts @@ -1,22 +1,13 @@ -import SecurityClient from '@app/components/utilities/SecurityClient'; +import { apiRequest } from '@app/config/request'; /** * Get the latest key pairs from a certain workspace * @param {string} workspaceId * @returns */ -const getLatestFileKey = ({ workspaceId }: { workspaceId: string }) => - SecurityClient.fetchCall(`/api/v1/key/${workspaceId}/latest`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - }).then(async (res) => { - if (res?.status === 200) { - return res.json(); - } - console.log('Failed to get the latest key pairs for a certain project'); - return undefined; - }); +const getLatestFileKey = async ({ workspaceId }: { workspaceId: string }) => { + const { data } = await apiRequest.get(`/api/v1/key/${workspaceId}/latest`); + return data; +} export default getLatestFileKey; diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index c3c625a79..be9e3718f 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -207,6 +207,9 @@ export default function Integrations() { case 'travisci': link = `${window.location.origin}/integrations/travisci/authorize`; break; + case 'railway': + link = `${window.location.origin}/integrations/railway/authorize`; + break; default: break; } @@ -259,6 +262,9 @@ export default function Integrations() { case 'travisci': link = `${window.location.origin}/integrations/travisci/create?integrationAuthId=${integrationAuth._id}`; break; + case 'railway': + 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..200628921 100644 --- a/frontend/src/pages/integrations/aws-parameter-store/create.tsx +++ b/frontend/src/pages/integrations/aws-parameter-store/create.tsx @@ -98,6 +98,9 @@ export default function AWSParameterStoreCreateIntegrationPage() { appId: null, 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 804cf0504..7f253e119 100644 --- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx @@ -97,6 +97,9 @@ export default function AWSSecretManagerCreateIntegrationPage() { appId: null, 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 bfd544748..8f506b6d4 100644 --- a/frontend/src/pages/integrations/azure-key-vault/create.tsx +++ b/frontend/src/pages/integrations/azure-key-vault/create.tsx @@ -62,6 +62,9 @@ export default function AzureKeyVaultCreateIntegrationPage() { appId: null, 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 b753e7dd7..05a53e609 100644 --- a/frontend/src/pages/integrations/circleci/create.tsx +++ b/frontend/src/pages/integrations/circleci/create.tsx @@ -60,6 +60,9 @@ export default function CircleCICreateIntegrationPage() { appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, 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 05b37affd..13dbc05d1 100644 --- a/frontend/src/pages/integrations/flyio/create.tsx +++ b/frontend/src/pages/integrations/flyio/create.tsx @@ -61,6 +61,9 @@ export default function FlyioCreateIntegrationPage() { appId: null, 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 c4368fe4f..eee9f8a71 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -64,6 +64,9 @@ export default function GitHubCreateIntegrationPage() { appId: null, 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 f4da8c9ff..c8a25e78b 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -89,6 +89,9 @@ export default function GitLabCreateIntegrationPage() { appId: targetAppId, 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 cae8dfe44..beb52c4d4 100644 --- a/frontend/src/pages/integrations/heroku/create.tsx +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -60,6 +60,9 @@ export default function HerokuCreateIntegrationPage() { appId: null, 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 fb33e404b..cf3a23038 100644 --- a/frontend/src/pages/integrations/netlify/create.tsx +++ b/frontend/src/pages/integrations/netlify/create.tsx @@ -69,6 +69,9 @@ export default function NetlifyCreateIntegrationPage() { appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/railway/authorize.tsx b/frontend/src/pages/integrations/railway/authorize.tsx new file mode 100644 index 000000000..94d3f06bf --- /dev/null +++ b/frontend/src/pages/integrations/railway/authorize.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; + +import { getTranslatedServerSideProps } from '../../../components/utilities/withTranslateProps'; +import { + Button, + Card, + CardTitle, + FormControl, + Input, +} from '../../../components/v2'; +import saveIntegrationAccessToken from "../../api/integrations/saveIntegrationAccessToken"; + +export default function RailwayAuthorizeIntegrationPage() { + const router = useRouter(); + const [apiKey, setApiKey] = useState(''); + const [apiKeyErrorText, setApiKeyErrorText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(''); + if (apiKey.length === 0) { + setApiKeyErrorText('API Key cannot be blank'); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem('projectData.id'), + integration: 'railway', + accessId: null, + accessToken: apiKey + }); + + setIsLoading(false); + + router.push( + `/integrations/railway/create?integrationAuthId=${integrationAuth._id}` + ); + } catch (err) { + console.error(err); + } + } + + return ( +
+ + Railway Integration + + setApiKey(e.target.value)} + /> + + + +
+ ); +} + +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..95eb90c6f --- /dev/null +++ b/frontend/src/pages/integrations/railway/create.tsx @@ -0,0 +1,207 @@ +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, + useGetIntegrationAuthRailwayEnvironments, + useGetIntegrationAuthRailwayServices +} 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 [targetServiceId, setTargetServiceId] = 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 ?? '' + }); + const { data: targetEnvironments } = useGetIntegrationAuthRailwayEnvironments({ + integrationAuthId: integrationAuthId as string ?? '', + appId: targetAppId + }); + const { data: targetServices } = useGetIntegrationAuthRailwayServices({ + integrationAuthId: integrationAuthId as string ?? '', + appId: targetAppId + }); + + 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]); + + useEffect(() => { + if (targetEnvironments) { + if (targetEnvironments.length > 0) { + setTargetEnvironmentId(targetEnvironments[0].environmentId); + } else { + setTargetEnvironmentId('none'); + } + } + }, [targetEnvironments]); + + const filteredServices = targetServices + ?.concat({ + name: '', + serviceId: '' + }); + + const handleButtonClick = async () => { + try { + setIsLoading(true); + + if (!integrationAuth?._id) return; + + const targetApp = integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId); + 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, + isActive: true, + app: targetApp.name, + appId: targetApp.appId, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: targetEnvironment.name, + targetEnvironmentId: targetEnvironment.environmentId, + targetService: targetService ? targetService.name : null, + targetServiceId: targetService ? targetService.serviceId : null, + owner: null, + path: null, + region: null + }); + + setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + } catch (err) { + console.error(err); + } + } + + return workspace && selectedSourceEnvironment && integrationAuthApps && targetEnvironments && filteredServices ? ( +
+ + 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..ce9d702ee 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"; @@ -38,7 +38,6 @@ export default function RenderCreateIntegrationPage() { }, [workspace]); useEffect(() => { - // TODO: handle case where apps can be empty if (integrationAuthApps) { if (integrationAuthApps.length > 0) { setTargetApp(integrationAuthApps[0].name); @@ -61,6 +60,9 @@ export default function RenderCreateIntegrationPage() { appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null diff --git a/frontend/src/pages/integrations/travisci/create.tsx b/frontend/src/pages/integrations/travisci/create.tsx index c4a8a8f95..7f4e93422 100644 --- a/frontend/src/pages/integrations/travisci/create.tsx +++ b/frontend/src/pages/integrations/travisci/create.tsx @@ -60,6 +60,9 @@ export default function TravisCICreateIntegrationPage() { appId: (integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.name === targetApp))?.appId ?? null, sourceEnvironment: selectedSourceEnvironment, targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path: null, region: null, diff --git a/frontend/src/pages/integrations/vercel/create.tsx b/frontend/src/pages/integrations/vercel/create.tsx index 0d74c6a94..531033957 100644 --- a/frontend/src/pages/integrations/vercel/create.tsx +++ b/frontend/src/pages/integrations/vercel/create.tsx @@ -87,6 +87,9 @@ export default function VercelCreateIntegrationPage() { appId: targetApp.appId, sourceEnvironment: selectedSourceEnvironment, targetEnvironment, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, owner: null, path, region: null 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..dff9486a8 --- /dev/null +++ b/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx @@ -0,0 +1,27 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import Head from 'next/head'; +import { useTranslation } from 'next-i18next'; + +import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; +import { CreateServiceAccountPage } from '@app/views/Settings/CreateServiceAccountPage'; + +export default function ServiceAccountPage() { + return ( + <> + + Edit Service Account + + + + + ); +} + +ServiceAccountPage.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps([ + 'settings', + 'settings-org', + 'section-incident', + 'section-members' +]); \ 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..2b331b244 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx @@ -0,0 +1,45 @@ +import { useRouter } from 'next/router'; + +import NavHeader from '@app/components/navigation/NavHeader'; + +import { SAProjectLevelPermissionsTable } from './components/SAProjectLevelPermissionsTable'; +import { + CopyServiceAccountIDSection, + ServiceAccountNameChangeSection} from './components'; + +export const CreateServiceAccountPage = () => { + const router = useRouter(); + const {serviceAccountId} = router.query; + + return ( +
+ +
+

Service Account

+

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

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

Service Account ID

+
+

{serviceAccountId}

+ copyServiceAccountIdToClipboard()} + > + + + Copy + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx new file mode 100644 index 000000000..01cae04d6 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/CopyServiceAccountIDSection/index.tsx @@ -0,0 +1 @@ +export { CopyServiceAccountIDSection } from './CopyServiceAccountIDSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx new file mode 100644 index 000000000..3e3da9fd7 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx @@ -0,0 +1,406 @@ +import { 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 { + decryptAssymmetric, + encryptAssymmetric, + verifyPrivateKey} from '@app/components/utilities/cryptography/crypto'; +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 { + useCreateServiceAccountProjectLevelPermission, + useDeleteServiceAccountProjectLevelPermission, + useGetServiceAccountById, + useGetServiceAccountProjectLevelPermissions, + useGetUserWorkspaces} from '@app/hooks/api'; +import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey'; + +const createProjectLevelPermissionSchema = yup.object({ + privateKey: yup.string().required().label('Private Key'), + workspace: yup.string().required().label('Workspace'), + environment: yup.string().required().label('Environment'), + permissions: yup.object().shape({ + read: yup.boolean().required(), + write: yup.boolean().required() + }).defined().required() +}); + +type CreateProjectLevelPermissionForm = yup.InferType; + +type Props = { + serviceAccountId: string; +} + +export const SAProjectLevelPermissionsTable = ({ + serviceAccountId +}: Props): JSX.Element => { + const { data: serviceAccount } = useGetServiceAccountById(serviceAccountId); + const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces(); + const [searchPermissions, setSearchPermissions] = useState(''); + + const { data: serviceAccountWorkspacePermissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId); + + const createServiceAccountProjectLevelPermission = useCreateServiceAccountProjectLevelPermission(); + const deleteServiceAccountProjectLevelPermission = useDeleteServiceAccountProjectLevelPermission(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addProjectLevelPermission', + 'removeProjectLevelPermission', + ] as const); + + const [, setSelectedWorkspace] = useState(undefined); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(createProjectLevelPermissionSchema) }) + + const onAddProjectLevelPermission = async ({ + privateKey, + workspace, + environment, + permissions: { read, write } + }: CreateProjectLevelPermissionForm) => { + + // TODO: clean up / modularize this function + + if (!serviceAccount) return; + + const { latestKey } = await getLatestFileKey({ + workspaceId: workspace + }); + + verifyPrivateKey({ + privateKey, + publicKey: serviceAccount.publicKey + }); + + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; + + const key = decryptAssymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: key, + publicKey: serviceAccount.publicKey, + privateKey + }); + + await createServiceAccountProjectLevelPermission.mutateAsync({ + serviceAccountId, + workspaceId: workspace, + environment, + read, + write, + encryptedKey: ciphertext, + nonce + }); + handlePopUpClose('addProjectLevelPermission'); + } + + const onRemoveProjectLevelPermission = async () => { + const serviceAccountWorkspacePermissionId = (popUp?.removeProjectLevelPermission?.data as { _id: string })?._id; + await deleteServiceAccountProjectLevelPermission.mutateAsync({ + serviceAccountId, + serviceAccountWorkspacePermissionId + }); + handlePopUpClose('removeProjectLevelPermission'); + } + + return ( +
+

Project-Level Permissions

+
+
+ setSearchPermissions(e.target.value)} + leftIcon={} + placeholder="Search service account project-level permissions..." + /> +
+ +
+ + + + + + + + + + + + {isPermissionsLoading && } + {!isPermissionsLoading && serviceAccountWorkspacePermissions && ( + serviceAccountWorkspacePermissions.map(({ + _id, + workspace, + environment, + read, + write + }) => { + const environmentName = (workspace.environments.find((env) => env.slug === environment))?.name; + return ( + + + + + + + + ); + }) + )} + {!isPermissionsLoading && serviceAccountWorkspacePermissions?.length === 0 && ( + + + + )} + +
ProjectEnvironmentReadWrite +
{workspace.name}{environmentName} + {/**/} + + {/**/} + + handlePopUpOpen('removeProjectLevelPermission', { _id })} + > + + +
+ +
+
+ { + handlePopUpToggle('addProjectLevelPermission', 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: 'read' + }, + { + label: 'Write', + value: 'write' + } + ]; + + return ( + + <> + {options.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} + + + ); + }} + /> +
+ + + + +
+ +
+
+ handlePopUpToggle('removeProjectLevelPermission', isOpen)} + onDeleteApproved={onRemoveProjectLevelPermission} + /> +
+ ); +} \ 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..99bf3653e --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx @@ -0,0 +1,3 @@ +export { CopyServiceAccountIDSection } from './CopyServiceAccountIDSection'; +export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable'; +export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/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 d3f8ba628..d5d8687b7 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -21,10 +21,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; @@ -37,12 +42,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(); @@ -233,17 +237,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')}

@@ -262,6 +264,12 @@ export const OrgSettingsPage = () => { setCompleteInviteLink={setcompleteInviteLink} />
+
+

+ 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 e118e455b..58de0e288 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx @@ -27,7 +27,8 @@ import { Td, Th, THead, - Tr} from '@app/components/v2'; + Tr +} from '@app/components/v2'; import { usePopUp } from '@app/hooks'; import { useFetchServerStatus } from '@app/hooks/api/serverDetails'; 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 04ee9da3d..5c2a1fc44 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -103,7 +103,7 @@ export const OrgMembersTable = ({ () => members.find(({ user }) => userId === user?._id)?.role === 'owner', [userId, members] ); - + const filterdUser = useMemo( () => members.filter( @@ -132,25 +132,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..55e2deebb --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx @@ -0,0 +1,345 @@ +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(); + + 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: Number(expiresIn) + }); + + setAccessKey(serviceAccountDetails.serviceAccountAccessKey); + + setStep(1); + reset(); + } + + const onRemoveServiceAccount = async () => { + const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id; + 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 ( +
+ ( + + + + )} + /> + { + 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
+ } + } + + 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) - - ); - }} - /> */}