diff --git a/.env.example b/.env.example index 1f3f64591..e98401462 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ JWT_SIGNUP_SECRET=3679e04ca949f914c03332aaaeba805a JWT_REFRESH_SECRET=5f2f3c8f0159068dc2bbb3a652a716ff JWT_AUTH_SECRET=4be6ba5602e0fa0ac6ac05c3cd4d247f JWT_SERVICE_SECRET=f32f716d70a42c5703f4656015e76200 +JWT_SERVICE_TOKEN_SECRET=f32f716d70a42c5703f4656015e76200 JWT_PROVIDER_AUTH_SECRET=f32f716d70a42c5703f4656015e76201 # JWT lifetime diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 5f055b73b..5c3e2f819 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -28,6 +28,7 @@ export const getJwtSignupLifetime = async () => (await client.getSecret("JWT_SIG export const getJwtProviderAuthSecret = async () => (await client.getSecret("JWT_PROVIDER_AUTH_SECRET")).secretValue; export const getJwtProviderAuthLifetime = async () => (await client.getSecret("JWT_PROVIDER_AUTH_LIFETIME")).secretValue || "15m"; export const getJwtSignupSecret = async () => (await client.getSecret("JWT_SIGNUP_SECRET")).secretValue; +export const getJwtServiceTokenSecret = async () => (await client.getSecret("JWT_SERVICE_TOKEN_SECRET")).secretValue; export const getMongoURL = async () => (await client.getSecret("MONGO_URL")).secretValue; export const getNodeEnv = async () => (await client.getSecret("NODE_ENV")).secretValue || "production"; export const getVerboseErrorOutput = async () => (await client.getSecret("VERBOSE_ERROR_OUTPUT")).secretValue === "true" && true; diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index 2f11ce5d4..9441734f9 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -395,9 +395,9 @@ export const renameWorkspaceEnvironment = async (req: Request, res: Response) => { environment: environmentSlug } ); await SecretImport.updateMany( - { workspace: workspaceId, 'imports.environment': oldEnvironmentSlug }, - { $set: { 'imports.$[element].environment': environmentSlug } }, - { arrayFilters: [{ 'element.environment': oldEnvironmentSlug }] }, + { workspace: workspaceId, "imports.environment": oldEnvironmentSlug }, + { $set: { "imports.$[element].environment": environmentSlug } }, + { arrayFilters: [{ "element.environment": oldEnvironmentSlug }] }, ); await ServiceAccountWorkspacePermission.updateMany( diff --git a/backend/src/controllers/v3/index.ts b/backend/src/controllers/v3/index.ts index 959bab532..e52b1a0ea 100644 --- a/backend/src/controllers/v3/index.ts +++ b/backend/src/controllers/v3/index.ts @@ -7,5 +7,5 @@ export { authController, secretsController, signupController, - workspacesController, + workspacesController } diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index b388f8305..482e5baa6 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -3,10 +3,21 @@ import { Types } from "mongoose"; import { EventService, SecretService } from "../../services"; import { eventPushSecrets } from "../../events"; import { BotService } from "../../services"; -import { containsGlobPatterns, repackageSecretToRaw } from "../../helpers/secrets"; +import { + containsGlobPatterns, + isValidScopeV3, + repackageSecretToRaw +} from "../../helpers/secrets"; import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto"; import { getAllImportedSecrets } from "../../services/SecretImportService"; -import { Folder, IServiceTokenData } from "../../models"; +import { + Folder, + IServiceTokenData, + IServiceTokenDataV3 +} from "../../models"; +import { + Permission +} from "../../models/serviceTokenDataV3"; import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService"; import { BadRequestError } from "../../utils/errors"; import { validateRequest } from "../../helpers/validation"; @@ -17,8 +28,99 @@ import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; import { ForbiddenError, subject } from "@casl/ability"; -import { validateServiceTokenDataClientForWorkspace } from "../../validation"; +import { + validateServiceTokenDataClientForWorkspace, + validateServiceTokenDataV3ClientForWorkspace +} from "../../validation"; import { PERMISSION_READ_SECRETS, PERMISSION_WRITE_SECRETS } from "../../variables"; +import { ActorType } from "../../ee/models"; +import { UnauthorizedRequestError } from "../../utils/errors"; +import { AuthData } from "../../interfaces/middleware"; + +const checkSecretsPermission = async ({ + authData, + workspaceId, + environment, + secretPath, + secretAction +}: { + authData: AuthData; + workspaceId: string; + environment: string; + secretPath: string; + secretAction: ProjectPermissionActions; // CRUD +}): Promise<(env: string, secPath: string) => boolean> => { + + let STV2RequiredPermissions = []; + let STV3RequiredPermissions: Permission[] = []; + + switch (secretAction) { + case ProjectPermissionActions.Create: + STV2RequiredPermissions = [PERMISSION_WRITE_SECRETS]; + STV3RequiredPermissions = [Permission.WRITE]; + break; + case ProjectPermissionActions.Read: + STV2RequiredPermissions = [PERMISSION_READ_SECRETS]; + STV3RequiredPermissions = [Permission.READ]; + break; + case ProjectPermissionActions.Edit: + STV2RequiredPermissions = [PERMISSION_WRITE_SECRETS]; + STV3RequiredPermissions = [Permission.WRITE]; + break; + case ProjectPermissionActions.Delete: + STV2RequiredPermissions = [PERMISSION_WRITE_SECRETS]; + STV3RequiredPermissions = [Permission.WRITE]; + break; + } + + switch (authData.actor.type) { + case ActorType.USER: { + const { permission } = await getUserProjectPermissions(authData.actor.metadata.userId, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + secretAction, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + return (env: string, secPath: string) => + permission.can( + secretAction, + subject(ProjectPermissionSub.Secrets, { + environment: env, + secretPath: secPath + }) + ); + } + case ActorType.SERVICE: { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload as IServiceTokenData, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: STV2RequiredPermissions + }); + return () => true; + } + case ActorType.SERVICE_V3: { + await validateServiceTokenDataV3ClientForWorkspace({ + authData, + serviceTokenData: authData.authPayload as IServiceTokenDataV3, + workspaceId: new Types.ObjectId(workspaceId), + environment, + secretPath, + requiredPermissions: STV3RequiredPermissions + }); + return (env: string, secPath: string) => + isValidScopeV3({ + authPayload: authData.authPayload as IServiceTokenDataV3, + environment: env, + secretPath: secPath, + requiredPermissions: STV3RequiredPermissions + }); + } + default: { + throw UnauthorizedRequestError(); + } + } +} /** * Return secrets for workspace with id [workspaceId] and environment @@ -58,31 +160,13 @@ export const getSecretsRaw = async (req: Request, res: Response) => { if (!environment || !workspaceId) throw BadRequestError({ message: "Missing environment or workspace id" }); - let permissionCheckFn: (env: string, secPath: string) => boolean; // used to pass as callback function to import secret - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - permissionCheckFn = (env: string, secPath: string) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: env, - secretPath: secPath - }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_READ_SECRETS] - }); - permissionCheckFn = () => true; - } + const permissionCheckFn = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Read + }); const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), @@ -148,22 +232,14 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => { query: { secretPath, environment, workspaceId, type, include_imports }, params: { secretName } } = await validateRequest(reqValidator.GetSecretByNameRawV3, req); - - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_READ_SECRETS] - }); - } + + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Read + }); const secret = await SecretService.getSecret({ secretName, @@ -206,21 +282,13 @@ export const createSecretRaw = async (req: Request, res: Response) => { } } = await validateRequest(reqValidator.CreateSecretRawV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Create + }); const key = await BotService.getWorkspaceKeyWithBot({ workspaceId: new Types.ObjectId(workspaceId) @@ -290,21 +358,13 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => { body: { secretValue, environment, secretPath, type, workspaceId, skipMultilineEncoding } } = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Edit + }); const key = await BotService.getWorkspaceKeyWithBot({ workspaceId: new Types.ObjectId(workspaceId) @@ -355,21 +415,13 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => { body: { environment, secretPath, type, workspaceId } } = await validateRequest(reqValidator.DeleteSecretByNameRawV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Delete + }); const { secret } = await SecretService.deleteSecret({ secretName, @@ -423,31 +475,13 @@ export const getSecrets = async (req: Request, res: Response) => { secretPath = getFolderWithPathFromId(folder.nodes, folderId).folderPath; } - let permissionCheckFn: (env: string, secPath: string) => boolean; // used to pass as callback function to import secret - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - permissionCheckFn = (env: string, secPath: string) => - permission.can( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { - environment: env, - secretPath: secPath - }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_READ_SECRETS] - }); - permissionCheckFn = () => true; - } + const permissionCheckFn = await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Read + }); const secrets = await SecretService.getSecrets({ workspaceId: new Types.ObjectId(workspaceId), @@ -495,22 +529,14 @@ export const getSecretByName = async (req: Request, res: Response) => { query: { secretPath, environment, workspaceId, type, include_imports }, params: { secretName } } = await validateRequest(reqValidator.GetSecretByNameV3, req); - - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_READ_SECRETS] - }); - } + + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Read + }); const secret = await SecretService.getSecret({ secretName, @@ -553,22 +579,14 @@ export const createSecret = async (req: Request, res: Response) => { }, params: { secretName } } = await validateRequest(reqValidator.CreateSecretV3, req); - - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Create + }); const secret = await SecretService.createSecret({ secretName, @@ -633,26 +651,19 @@ export const updateSecretByName = async (req: Request, res: Response) => { }, params: { secretName } } = await validateRequest(reqValidator.UpdateSecretByNameV3, req); - - if (newSecretName && (!secretKeyIV || !secretKeyTag || !secretKeyCiphertext)) + + if (newSecretName && (!secretKeyIV || !secretKeyTag || !secretKeyCiphertext)) { throw BadRequestError({ message: "Missing encrypted key" }); - - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Edit + }); + const secret = await SecretService.updateSecret({ secretName, workspaceId: new Types.ObjectId(workspaceId), @@ -698,21 +709,13 @@ export const deleteSecretByName = async (req: Request, res: Response) => { params: { secretName } } = await validateRequest(reqValidator.DeleteSecretByNameV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Delete + }); const { secret } = await SecretService.deleteSecret({ secretName, @@ -740,22 +743,14 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => { const { body: { secrets, secretPath, environment, workspaceId } } = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req); - - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Create + }); const createdSecrets = await SecretService.createSecretBatch({ secretPath, @@ -775,21 +770,13 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => { body: { secrets, secretPath, environment, workspaceId } } = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Edit + }); const updatedSecrets = await SecretService.updateSecretBatch({ secretPath, @@ -809,21 +796,13 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => { body: { secrets, secretPath, environment, workspaceId } } = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req); - if (req.user?._id) { - const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); - } else { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: req.authData.authPayload as IServiceTokenData, - workspaceId: new Types.ObjectId(workspaceId), - environment, - secretPath, - requiredPermissions: [PERMISSION_WRITE_SECRETS] - }); - } + await checkSecretsPermission({ + authData: req.authData, + workspaceId, + environment, + secretPath, + secretAction: ProjectPermissionActions.Delete + }); const deletedSecrets = await SecretService.deleteSecretBatch({ secretPath, @@ -836,4 +815,4 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => { return res.status(200).send({ secrets: deletedSecrets }); -}; +}; \ No newline at end of file diff --git a/backend/src/controllers/v3/workspacesController.ts b/backend/src/controllers/v3/workspacesController.ts index 8469cdb5e..f1edca1f7 100644 --- a/backend/src/controllers/v3/workspacesController.ts +++ b/backend/src/controllers/v3/workspacesController.ts @@ -1,7 +1,7 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { validateRequest } from "../../helpers/validation"; -import { Secret } from "../../models"; +import { Secret, ServiceTokenDataV3 } from "../../models"; import { SecretService } from "../../services"; import { getUserProjectPermissions } from "../../ee/services/ProjectRoleService"; import { UnauthorizedRequestError } from "../../utils/errors"; @@ -101,3 +101,17 @@ export const nameWorkspaceSecrets = async (req: Request, res: Response) => { message: "Successfully named workspace secrets" }); }; + +export const getWorkspaceServiceTokenData = async (req: Request, res: Response) => { + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceServiceTokenDataV3, req); + + const serviceTokenData = await ServiceTokenDataV3.find({ + workspace: new Types.ObjectId(workspaceId) + }); + + return res.status(200).send({ + serviceTokenData + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index cef139c5c..3ee7a3229 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -5,6 +5,7 @@ import { Membership, Secret, ServiceTokenData, + ServiceTokenDataV3, TFolderSchema, User, Workspace @@ -20,6 +21,7 @@ import { SecretSnapshot, SecretVersion, ServiceActor, + ServiceActorV3, TFolderRootVersionSchema, TrustedIP, UserActor @@ -683,7 +685,7 @@ export const getWorkspaceAuditLogs = async (req: Request, res: Response) => { ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs ); - + const query = { workspace: new Types.ObjectId(workspaceId), ...(eventType @@ -698,13 +700,13 @@ export const getWorkspaceAuditLogs = async (req: Request, res: Response) => { : {}), ...(actor ? { - "actor.type": actor.split("-", 2)[0], + "actor.type": actor.substring(0, actor.lastIndexOf("-")), ...(actor.split("-", 2)[0] === ActorType.USER ? { - "actor.metadata.userId": actor.split("-", 2)[1] + "actor.metadata.userId": actor.substring(actor.lastIndexOf("-") + 1) } : { - "actor.metadata.serviceId": actor.split("-", 2)[1] + "actor.metadata.serviceId": actor.substring(actor.lastIndexOf("-") + 1) }) } : {}), @@ -772,9 +774,27 @@ export const getWorkspaceAuditLogActorFilterOpts = async (req: Request, res: Res name: serviceTokenData.name } })); + + const serviceV3Actors: ServiceActorV3[] = ( + await ServiceTokenDataV3.find({ + workspace: new Types.ObjectId(workspaceId) + }) + ).map((serviceTokenData) => ({ + type: ActorType.SERVICE_V3, + metadata: { + serviceId: serviceTokenData._id.toString(), + name: serviceTokenData.name + } + })); + + const actors = [ + ...userActors, + ...serviceActors, + ...serviceV3Actors + ]; return res.status(200).send({ - actors: [...userActors, ...serviceActors] + actors }); }; diff --git a/backend/src/ee/controllers/v3/index.ts b/backend/src/ee/controllers/v3/index.ts new file mode 100644 index 000000000..af6f3d306 --- /dev/null +++ b/backend/src/ee/controllers/v3/index.ts @@ -0,0 +1,5 @@ +import * as serviceTokenDataController from "./serviceTokenDataController"; + +export { + serviceTokenDataController +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v3/serviceTokenDataController.ts b/backend/src/ee/controllers/v3/serviceTokenDataController.ts new file mode 100644 index 000000000..3f57602f9 --- /dev/null +++ b/backend/src/ee/controllers/v3/serviceTokenDataController.ts @@ -0,0 +1,321 @@ +import { Request, Response } from "express"; +import { Types } from "mongoose"; +import { + IServiceTokenDataV3, + IUser, + ServiceTokenDataV3, + ServiceTokenDataV3Key, + Workspace +} from "../../../models"; +import { + IServiceTokenV3Scope, + IServiceTokenV3TrustedIp +} from "../../../models/serviceTokenDataV3"; +import { + ActorType, + EventType +} from "../../models"; +import { validateRequest } from "../../../helpers/validation"; +import * as reqValidator from "../../../validation/serviceTokenDataV3"; +import { createToken } from "../../../helpers/auth"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + getUserProjectPermissions +} from "../../services/ProjectRoleService"; +import { ForbiddenError } from "@casl/ability"; +import { BadRequestError, ResourceNotFoundError } from "../../../utils/errors"; +import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; +import { EEAuditLogService, EELicenseService } from "../../services"; +import { getJwtServiceTokenSecret } from "../../../config"; + +/** + * Return project key for service token + * @param req + * @param res + */ +export const getServiceTokenDataKey = async (req: Request, res: Response) => { + const key = await ServiceTokenDataV3Key.findOne({ + serviceTokenData: (req.authData.authPayload as IServiceTokenDataV3)._id + }).populate<{ sender: IUser }>("sender", "publicKey"); + + if (!key) throw ResourceNotFoundError({ + message: "Failed to find project key for service token" + }); + + const { _id, workspace, encryptedKey, nonce, sender: { publicKey } } = key; + + return res.status(200).send({ + key: { + _id, + workspace, + encryptedKey, + publicKey, + nonce + } + }); +} + +/** + * Create service token data + * @param req + * @param res + * @returns + */ +export const createServiceTokenData = async (req: Request, res: Response) => { + const { + body: { + name, + workspaceId, + publicKey, + scopes, + trustedIps, + expiresIn, + encryptedKey, // for ServiceTokenDataV3Key + nonce // for ServiceTokenDataV3Key + } + } = await validateRequest(reqValidator.CreateServiceTokenV3, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.ServiceTokens + ); + + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + + const plan = await EELicenseService.getPlan(workspace.organization); + + // validate trusted ips + const reformattedTrustedIps = trustedIps.map((trustedIp) => { + if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(trustedIp.ipAddress); + }); + + let expiresAt; + if (expiresIn) { + expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + } + + let user; + if (req.authData.actor.type === ActorType.USER) { + user = req.authData.authPayload._id; + } + + const isActive = true; + const serviceTokenData = await new ServiceTokenDataV3({ + name, + user, + workspace: new Types.ObjectId(workspaceId), + publicKey, + usageCount: 0, + trustedIps: reformattedTrustedIps, + scopes, + isActive, + expiresAt + }).save(); + + await new ServiceTokenDataV3Key({ + encryptedKey, + nonce, + sender: req.user._id, + serviceTokenData: serviceTokenData._id, + workspace: new Types.ObjectId(workspaceId) + }).save(); + + const token = createToken({ + payload: { + _id: serviceTokenData._id.toString() + }, + expiresIn, + secret: await getJwtServiceTokenSecret() + }); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.CREATE_SERVICE_TOKEN_V3, + metadata: { + name, + isActive, + scopes: scopes as Array, + trustedIps: reformattedTrustedIps as Array, + expiresAt + } + }, + { + workspaceId: new Types.ObjectId(workspaceId) + } + ); + + return res.status(200).send({ + serviceTokenData, + serviceToken: `stv3.${token}` + }); +} + +/** + * Update service token data with id [serviceTokenDataId] + * @param req + * @param res + * @returns + */ +export const updateServiceTokenData = async (req: Request, res: Response) => { + const { + params: { serviceTokenDataId }, + body: { + name, + isActive, + scopes, + trustedIps, + expiresIn + } + } = await validateRequest(reqValidator.UpdateServiceTokenV3, req); + + let serviceTokenData = await ServiceTokenDataV3.findById(serviceTokenDataId); + if (!serviceTokenData) throw ResourceNotFoundError({ + message: "Service token not found" + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + serviceTokenData.workspace.toString() + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.ServiceTokens + ); + + const workspace = await Workspace.findById(serviceTokenData.workspace); + if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + + const plan = await EELicenseService.getPlan(workspace.organization); + + // validate trusted ips + let reformattedTrustedIps; + if (trustedIps) { + reformattedTrustedIps = trustedIps.map((trustedIp) => { + if (!plan.ipAllowlisting && trustedIp.ipAddress !== "0.0.0.0/0") return res.status(400).send({ + message: "Failed to update IP access range to service token due to plan restriction. Upgrade plan to update IP access range." + }); + + const isValidIPOrCidr = isValidIpOrCidr(trustedIp.ipAddress); + + if (!isValidIPOrCidr) return res.status(400).send({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + + return extractIPDetails(trustedIp.ipAddress); + }); + } + + let expiresAt; + if (expiresIn) { + expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + } + + serviceTokenData = await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenDataId, + { + name, + isActive, + scopes, + trustedIps: reformattedTrustedIps, + expiresAt + }, + { + new: true + } + ); + + if (!serviceTokenData) throw BadRequestError({ + message: "Failed to update service token" + }); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.UPDATE_SERVICE_TOKEN_V3, + metadata: { + name: serviceTokenData.name, + isActive, + scopes: scopes as Array, + trustedIps: reformattedTrustedIps as Array, + expiresAt + } + }, + { + workspaceId: serviceTokenData.workspace + } + ); + + return res.status(200).send({ + serviceTokenData + }); +} + +/** + * Delete service token data with id [serviceTokenDataId] + * @param req + * @param res + * @returns + */ +export const deleteServiceTokenData = async (req: Request, res: Response) => { + const { + params: { serviceTokenDataId } + } = await validateRequest(reqValidator.DeleteServiceTokenV3, req); + + let serviceTokenData = await ServiceTokenDataV3.findById(serviceTokenDataId); + if (!serviceTokenData) throw ResourceNotFoundError({ + message: "Service token not found" + }); + + const { permission } = await getUserProjectPermissions( + req.user._id, + serviceTokenData.workspace.toString() + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.ServiceTokens + ); + + serviceTokenData = await ServiceTokenDataV3.findByIdAndDelete(serviceTokenDataId); + + if (!serviceTokenData) throw BadRequestError({ + message: "Failed to delete service token" + }); + + await EEAuditLogService.createAuditLog( + req.authData, + { + type: EventType.DELETE_SERVICE_TOKEN_V3, + metadata: { + name: serviceTokenData.name, + isActive: serviceTokenData.isActive, + scopes: serviceTokenData.scopes as Array, + trustedIps: serviceTokenData.trustedIps as Array, + expiresAt: serviceTokenData.expiresAt + } + }, + { + workspaceId: serviceTokenData.workspace + } + ); + + return res.status(200).send({ + serviceTokenData + }); +} \ No newline at end of file diff --git a/backend/src/ee/models/auditLog/enums.ts b/backend/src/ee/models/auditLog/enums.ts index c39b26502..3111db0af 100644 --- a/backend/src/ee/models/auditLog/enums.ts +++ b/backend/src/ee/models/auditLog/enums.ts @@ -1,6 +1,7 @@ export enum ActorType { - USER = "user", - SERVICE = "service" + USER = "user", + SERVICE = "service", + SERVICE_V3 = "service-v3" } export enum UserAgentType { @@ -11,40 +12,43 @@ export enum UserAgentType { } export enum EventType { - GET_SECRETS = "get-secrets", - GET_SECRET = "get-secret", - REVEAL_SECRET = "reveal-secret", - CREATE_SECRET = "create-secret", - CREATE_SECRETS = "create-secrets", - UPDATE_SECRET = "update-secret", - UPDATE_SECRETS = "update-secrets", - DELETE_SECRET = "delete-secret", - DELETE_SECRETS = "delete-secrets", - GET_WORKSPACE_KEY = "get-workspace-key", - AUTHORIZE_INTEGRATION = "authorize-integration", - UNAUTHORIZE_INTEGRATION = "unauthorize-integration", - CREATE_INTEGRATION = "create-integration", - DELETE_INTEGRATION = "delete-integration", - ADD_TRUSTED_IP = "add-trusted-ip", - UPDATE_TRUSTED_IP = "update-trusted-ip", - DELETE_TRUSTED_IP = "delete-trusted-ip", - CREATE_SERVICE_TOKEN = "create-service-token", - DELETE_SERVICE_TOKEN = "delete-service-token", - CREATE_ENVIRONMENT = "create-environment", - UPDATE_ENVIRONMENT = "update-environment", - DELETE_ENVIRONMENT = "delete-environment", - ADD_WORKSPACE_MEMBER = "add-workspace-member", - REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", - CREATE_FOLDER = "create-folder", - UPDATE_FOLDER = "update-folder", - DELETE_FOLDER = "delete-folder", - CREATE_WEBHOOK = "create-webhook", - UPDATE_WEBHOOK_STATUS = "update-webhook-status", - DELETE_WEBHOOK = "delete-webhook", - GET_SECRET_IMPORTS = "get-secret-imports", - CREATE_SECRET_IMPORT = "create-secret-import", - UPDATE_SECRET_IMPORT = "update-secret-import", - DELETE_SECRET_IMPORT = "delete-secret-import", - UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" -} + GET_SECRETS = "get-secrets", + GET_SECRET = "get-secret", + REVEAL_SECRET = "reveal-secret", + CREATE_SECRET = "create-secret", + CREATE_SECRETS = "create-secrets", + UPDATE_SECRET = "update-secret", + UPDATE_SECRETS = "update-secrets", + DELETE_SECRET = "delete-secret", + DELETE_SECRETS = "delete-secrets", + GET_WORKSPACE_KEY = "get-workspace-key", + AUTHORIZE_INTEGRATION = "authorize-integration", + UNAUTHORIZE_INTEGRATION = "unauthorize-integration", + CREATE_INTEGRATION = "create-integration", + DELETE_INTEGRATION = "delete-integration", + ADD_TRUSTED_IP = "add-trusted-ip", + UPDATE_TRUSTED_IP = "update-trusted-ip", + DELETE_TRUSTED_IP = "delete-trusted-ip", + CREATE_SERVICE_TOKEN = "create-service-token", // v2 + DELETE_SERVICE_TOKEN = "delete-service-token", // v2 + CREATE_SERVICE_TOKEN_V3 = "create-service-token-v3", // v3 + UPDATE_SERVICE_TOKEN_V3 = "update-service-token-v3", // v3 + DELETE_SERVICE_TOKEN_V3 = "delete-service-token-v3", // v3 + CREATE_ENVIRONMENT = "create-environment", + UPDATE_ENVIRONMENT = "update-environment", + DELETE_ENVIRONMENT = "delete-environment", + ADD_WORKSPACE_MEMBER = "add-workspace-member", + REMOVE_WORKSPACE_MEMBER = "remove-workspace-member", + CREATE_FOLDER = "create-folder", + UPDATE_FOLDER = "update-folder", + DELETE_FOLDER = "delete-folder", + CREATE_WEBHOOK = "create-webhook", + UPDATE_WEBHOOK_STATUS = "update-webhook-status", + DELETE_WEBHOOK = "delete-webhook", + GET_SECRET_IMPORTS = "get-secret-imports", + CREATE_SECRET_IMPORT = "create-secret-import", + UPDATE_SECRET_IMPORT = "update-secret-import", + DELETE_SECRET_IMPORT = "delete-secret-import", + UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", + UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" +} \ No newline at end of file diff --git a/backend/src/ee/models/auditLog/types.ts b/backend/src/ee/models/auditLog/types.ts index c03b109db..2f9fdb41c 100644 --- a/backend/src/ee/models/auditLog/types.ts +++ b/backend/src/ee/models/auditLog/types.ts @@ -1,4 +1,11 @@ -import { ActorType, EventType } from "./enums"; +import { + ActorType, + EventType +} from "./enums"; +import { + IServiceTokenV3Scope, + IServiceTokenV3TrustedIp +} from "../../../models/serviceTokenDataV3"; interface UserActorMetadata { userId: string; @@ -20,7 +27,15 @@ export interface ServiceActor { metadata: ServiceActorMetadata; } -export type Actor = UserActor | ServiceActor; +export interface ServiceActorV3 { + type: ActorType.SERVICE_V3; + metadata: ServiceActorMetadata; +} + +export type Actor = + | UserActor + | ServiceActor + | ServiceActorV3; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -210,6 +225,39 @@ interface DeleteServiceTokenEvent { }; } +interface CreateServiceTokenV3Event { + type: EventType.CREATE_SERVICE_TOKEN_V3; + metadata: { + name: string; + isActive: boolean; + scopes: Array; + trustedIps: Array; + expiresAt?: Date; + } +} + +interface UpdateServiceTokenV3Event { + type: EventType.UPDATE_SERVICE_TOKEN_V3; + metadata: { + name?: string; + isActive?: boolean; + scopes?: Array; + trustedIps?: Array; + expiresAt?: Date; + } +} + +interface DeleteServiceTokenV3Event { + type: EventType.DELETE_SERVICE_TOKEN_V3; + metadata: { + name: string; + isActive: boolean; + scopes: Array; + expiresAt?: Date; + trustedIps: Array; + } +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -379,50 +427,53 @@ interface UpdateUserRole { } interface UpdateUserDeniedPermissions { - type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS; - metadata: { - userId: string; - email: string; - deniedPermissions: { - environmentSlug: string; - ability: string; - }[]; - }; + type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS, + metadata: { + userId: string; + email: string; + deniedPermissions: { + environmentSlug: string; + ability: string; + }[] + } } -export type Event = - | GetSecretsEvent - | GetSecretEvent - | CreateSecretEvent - | CreateSecretBatchEvent - | UpdateSecretEvent - | UpdateSecretBatchEvent - | DeleteSecretEvent - | DeleteSecretBatchEvent - | GetWorkspaceKeyEvent - | AuthorizeIntegrationEvent - | UnauthorizeIntegrationEvent - | CreateIntegrationEvent - | DeleteIntegrationEvent - | AddTrustedIPEvent - | UpdateTrustedIPEvent - | DeleteTrustedIPEvent - | CreateServiceTokenEvent - | DeleteServiceTokenEvent - | CreateEnvironmentEvent - | UpdateEnvironmentEvent - | DeleteEnvironmentEvent - | AddWorkspaceMemberEvent - | RemoveWorkspaceMemberEvent - | CreateFolderEvent - | UpdateFolderEvent - | DeleteFolderEvent - | CreateWebhookEvent - | UpdateWebhookStatusEvent - | DeleteWebhookEvent - | GetSecretImportsEvent - | CreateSecretImportEvent - | UpdateSecretImportEvent - | DeleteSecretImportEvent - | UpdateUserRole - | UpdateUserDeniedPermissions; +export type Event = + | GetSecretsEvent + | GetSecretEvent + | CreateSecretEvent + | CreateSecretBatchEvent + | UpdateSecretEvent + | UpdateSecretBatchEvent + | DeleteSecretEvent + | DeleteSecretBatchEvent + | GetWorkspaceKeyEvent + | AuthorizeIntegrationEvent + | UnauthorizeIntegrationEvent + | CreateIntegrationEvent + | DeleteIntegrationEvent + | AddTrustedIPEvent + | UpdateTrustedIPEvent + | DeleteTrustedIPEvent + | CreateServiceTokenEvent + | DeleteServiceTokenEvent + | CreateServiceTokenV3Event + | UpdateServiceTokenV3Event + | DeleteServiceTokenV3Event + | CreateEnvironmentEvent + | UpdateEnvironmentEvent + | DeleteEnvironmentEvent + | AddWorkspaceMemberEvent + | RemoveWorkspaceMemberEvent + | CreateFolderEvent + | UpdateFolderEvent + | DeleteFolderEvent + | CreateWebhookEvent + | UpdateWebhookStatusEvent + | DeleteWebhookEvent + | GetSecretImportsEvent + | CreateSecretImportEvent + | UpdateSecretImportEvent + | DeleteSecretImportEvent + | UpdateUserRole + | UpdateUserDeniedPermissions; \ No newline at end of file diff --git a/backend/src/ee/routes/v3/index.ts b/backend/src/ee/routes/v3/index.ts new file mode 100644 index 000000000..7f75a2755 --- /dev/null +++ b/backend/src/ee/routes/v3/index.ts @@ -0,0 +1,5 @@ +import serviceTokenData from "./serviceTokenData"; + +export { + serviceTokenData +} \ No newline at end of file diff --git a/backend/src/ee/routes/v3/serviceTokenData.ts b/backend/src/ee/routes/v3/serviceTokenData.ts new file mode 100644 index 000000000..2f421c7ec --- /dev/null +++ b/backend/src/ee/routes/v3/serviceTokenData.ts @@ -0,0 +1,39 @@ +import express from "express"; +const router = express.Router(); +import { requireAuth } from "../../../middleware"; +import { AuthMode } from "../../../variables"; +import { serviceTokenDataController } from "../../controllers/v3"; + +router.get( + "/me/key", + requireAuth({ + acceptedAuthModes: [AuthMode.SERVICE_TOKEN_V3] + }), + serviceTokenDataController.getServiceTokenDataKey +); + +router.post( + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + serviceTokenDataController.createServiceTokenData +); + +router.patch( + "/:serviceTokenDataId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + serviceTokenDataController.updateServiceTokenData +); + +router.delete( + "/:serviceTokenDataId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + serviceTokenDataController.deleteServiceTokenData +); + +export default router; \ No newline at end of file diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index 74094cba6..31e584fe5 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -7,6 +7,7 @@ import { ITokenVersion, IUser, ServiceTokenData, + ServiceTokenDataV3, TokenVersion, User, } from "../models"; @@ -23,12 +24,14 @@ import { getJwtProviderAuthSecret, getJwtRefreshLifetime, getJwtRefreshSecret, + getJwtServiceTokenSecret } from "../config"; import { AuthMode } from "../variables"; import { ServiceTokenAuthData, + ServiceTokenV3AuthData, UserAuthData } from "../interfaces/middleware"; @@ -47,6 +50,9 @@ export const validateAuthMode = ({ headers: { [key: string]: string | string[] | undefined }, acceptedAuthModes: AuthMode[] }) => { + + // TODO: update this to accept service token v3 + const apiKey = headers["x-api-key"]; const authHeader = headers["authorization"]; @@ -65,6 +71,7 @@ export const validateAuthMode = ({ if (typeof authHeader === "string") { // case: treat request authentication type as via Authorization header (i.e. either JWT or service token) const [tokenType, tokenValue] = <[string, string]>authHeader.split(" ", 2) ?? [null, null] + if (tokenType === null) throw BadRequestError({ message: "Missing Authorization Header in the request header." }); if (tokenType.toLowerCase() !== "bearer") @@ -72,15 +79,21 @@ export const validateAuthMode = ({ if (tokenValue === null) throw BadRequestError({ message: "Missing Authorization Body in the request header." }); - switch (tokenValue.split(".", 1)[0]) { + const parts = tokenValue.split("."); + + switch (parts[0]) { case "st": authMode = AuthMode.SERVICE_TOKEN; + authTokenValue = tokenValue; + break; + case "stv3": + authMode = AuthMode.SERVICE_TOKEN_V3; + authTokenValue = parts.slice(1).join("."); break; default: authMode = AuthMode.JWT; + authTokenValue = tokenValue; } - - authTokenValue = tokenValue; } if (!authMode || !authTokenValue) throw BadRequestError({ message: "Missing valid Authorization or X-API-KEY in request header." }); @@ -211,8 +224,73 @@ export const getAuthSTDPayload = async ({ userAgent: req.headers["user-agent"] ?? "", userAgentType: getUserAgentType(req.headers["user-agent"]) } +} - // return serviceTokenDataToReturn; +/** + * Return service token data V3 payload corresponding to service token [authTokenValue] + * @param {Object} obj + * @param {String} obj.authTokenValue - service token value + * @returns {ServiceTokenData} serviceTokenData - service token data + */ + export const getAuthSTDV3Payload = async ({ + req, + authTokenValue, +}: { + req: Request, + authTokenValue: string; +}): Promise => { + const decodedToken = ( + jwt.verify(authTokenValue, await getJwtServiceTokenSecret()) + ); + + const serviceTokenData = await ServiceTokenDataV3.findOneAndUpdate( + { + _id: new Types.ObjectId(decodedToken._id), + isActive: true + }, + { + lastUsed: new Date(), + $inc: { usageCount: 1 } + }, + { + new: true + } + ); + + if (!serviceTokenData) { + throw UnauthorizedRequestError({ + message: "Failed to authenticate" + }); + } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { + // case: service token expired + await ServiceTokenDataV3.findByIdAndUpdate( + serviceTokenData._id, + { + isActive: false + }, + { + new: true + } + ); + + throw UnauthorizedRequestError({ + message: "Failed to authenticate", + }); + } + + return { + actor: { + type: ActorType.SERVICE_V3, + metadata: { + serviceId: serviceTokenData._id.toString(), + name: serviceTokenData.name + } + }, + authPayload: serviceTokenData, + ipAddress: req.realIP, + userAgent: req.headers["user-agent"] ?? "", + userAgentType: getUserAgentType(req.headers["user-agent"]) + } } /** @@ -382,11 +460,15 @@ export const createToken = ({ secret, }: { payload: any; - expiresIn: string | number; + expiresIn?: string | number; secret: string; }) => { return jwt.sign(payload, secret, { - expiresIn, + ...( + (expiresIn !== undefined && expiresIn !== null) + ? { expiresIn } + : {} + ) }); }; diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 7380f272d..6f718f5fe 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -13,11 +13,13 @@ import { Folder, ISecret, IServiceTokenData, + IServiceTokenDataV3, Secret, SecretBlindIndexData, ServiceTokenData, TFolderRootSchema } from "../models"; +import { Permission } from "../models/serviceTokenDataV3"; import { EventType, SecretVersion } from "../ee/models"; import { BadRequestError, @@ -53,10 +55,50 @@ import picomatch from "picomatch"; import path from "path"; import { getAnImportedSecret } from "../services/SecretImportService"; +/** + * Validate scope for service token v3 + * @param authPayload + * @param environment + * @param secretPath + * @returns + */ +export const isValidScopeV3 = ({ + authPayload, + environment, + secretPath, + requiredPermissions +}: { + authPayload: IServiceTokenDataV3, + environment: string, + secretPath: string, + requiredPermissions: Permission[] +}) => { + const { scopes } = authPayload; + + const validScope = scopes.find( + (scope) => + picomatch.isMatch(secretPath, scope.secretPath, { strictSlashes: false }) && + scope.environment === environment + ); + + if (validScope && !requiredPermissions.every(permission => validScope.permissions.includes(permission))) { + return false; + } + + return Boolean(validScope); +} + +/** + * Validate scope for service token v2 + * @param authPayload + * @param environment + * @param secretPath + * @returns + */ export const isValidScope = ( authPayload: IServiceTokenData, environment: string, - secretPath: string + secretPath: string, ) => { const { scopes: tkScopes } = authPayload; const validScope = tkScopes.find( diff --git a/backend/src/index.ts b/backend/src/index.ts index c516f06c2..602da13ec 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,9 @@ import { roles as v1RoleRouter, secretScanning as v1SecretScanningRouter } from "./ee/routes/v1"; +import { + serviceTokenData as v3ServiceTokenDataRouter +} from "./ee/routes/v3"; import { auth as v1AuthRouter, bot as v1BotRouter, @@ -56,7 +59,6 @@ import { organizations as v2OrganizationsRouter, secret as v2SecretRouter, // begin to phase out secrets as v2SecretsRouter, - serviceAccounts as v2ServiceAccountsRouter, serviceTokenData as v2ServiceTokenDataRouter, signup as v2SignupRouter, tags as v2TagsRouter, @@ -155,6 +157,7 @@ const main = async () => { app.use("/api/v1/organizations", eeOrganizationsRouter); app.use("/api/v1/sso", eeSSORouter); app.use("/api/v1/cloud-products", eeCloudProductsRouter); + app.use("/api/v3/service-token", v3ServiceTokenDataRouter); // v1 routes app.use("/api/v1/signup", v1SignupRouter); @@ -192,7 +195,7 @@ const main = async () => { app.use("/api/v2/secret", v2SecretRouter); // deprecate app.use("/api/v2/secrets", v2SecretsRouter); // note: in the process of moving to v3/secrets app.use("/api/v2/service-token", v2ServiceTokenDataRouter); - app.use("/api/v2/service-accounts", v2ServiceAccountsRouter); // new + // app.use("/api/v2/service-accounts", v2ServiceAccountsRouter); // new // v3 routes (experimental) app.use("/api/v3/auth", v3AuthRouter); diff --git a/backend/src/interfaces/middleware/index.ts b/backend/src/interfaces/middleware/index.ts index e8dd1b91b..5146d25cd 100644 --- a/backend/src/interfaces/middleware/index.ts +++ b/backend/src/interfaces/middleware/index.ts @@ -1,10 +1,12 @@ import { Types } from "mongoose"; import { IServiceTokenData, + IServiceTokenDataV3, IUser, } from "../../models"; import { ServiceActor, + ServiceActorV3, UserActor, UserAgentType } from "../../ee/models"; @@ -21,6 +23,11 @@ export interface UserAuthData extends BaseAuthData { authPayload: IUser; } +export interface ServiceTokenV3AuthData extends BaseAuthData { + actor: ServiceActorV3; + authPayload: IServiceTokenDataV3; +} + export interface ServiceTokenAuthData extends BaseAuthData { actor: ServiceActor; authPayload: IServiceTokenData; @@ -28,4 +35,5 @@ export interface ServiceTokenAuthData extends BaseAuthData { export type AuthData = | UserAuthData + | ServiceTokenV3AuthData | ServiceTokenAuthData; \ No newline at end of file diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index e256f6665..aa74b0dc7 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -3,6 +3,7 @@ import { NextFunction, Request, Response } from "express"; import { getAuthAPIKeyPayload, getAuthSTDPayload, + getAuthSTDV3Payload, getAuthUserPayload, validateAuthMode, } from "../helpers/auth"; @@ -49,6 +50,12 @@ const requireAuth = ({ }); req.serviceTokenData = authData.authPayload; break; + case AuthMode.SERVICE_TOKEN_V3: + authData = await getAuthSTDV3Payload({ + req, + authTokenValue + }); + break; case AuthMode.API_KEY: authData = await getAuthAPIKeyPayload({ req, @@ -61,9 +68,7 @@ const requireAuth = ({ req, authTokenValue }); - // authPayload = authUserPayload.user; req.user = authData.authPayload; - // req.tokenVersionId = authUserPayload.tokenVersionId; // TODO break; } diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 7a431592b..99fc9c4f1 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -14,17 +14,19 @@ export * from "./tag"; export * from "./folder"; export * from "./secretImports"; export * from "./secretBlindIndexData"; -export * from "./serviceToken"; -export * from "./serviceAccount"; -export * from "./serviceAccountKey"; -export * from "./serviceAccountOrganizationPermission"; -export * from "./serviceAccountWorkspacePermission"; +export * from "./serviceToken"; // TODO: deprecate +export * from "./serviceAccount"; // TODO: deprecate +export * from "./serviceAccountKey"; // TODO: deprecate +export * from "./serviceAccountOrganizationPermission"; // TODO: deprecate +export * from "./serviceAccountWorkspacePermission"; // TODO: deprecate export * from "./tokenData"; export * from "./user"; export * from "./userAction"; export * from "./workspace"; -export * from "./serviceTokenData"; +export * from "./serviceTokenData"; // TODO: deprecate export * from "./apiKeyData"; export * from "./loginSRPDetail"; export * from "./tokenVersion"; -export * from "./webhooks"; \ No newline at end of file +export * from "./webhooks"; +export * from "./serviceTokenDataV3"; +export * from "./serviceTokenDataV3Key"; diff --git a/backend/src/models/serviceToken.ts b/backend/src/models/serviceToken.ts index 4734a50e2..0e943b177 100644 --- a/backend/src/models/serviceToken.ts +++ b/backend/src/models/serviceToken.ts @@ -1,3 +1,4 @@ +// TODO: deprecate import { Schema, Types, model } from "mongoose"; export interface IServiceToken { _id: Types.ObjectId; diff --git a/backend/src/models/serviceTokenData.ts b/backend/src/models/serviceTokenData.ts index ea7d00eaa..735131703 100644 --- a/backend/src/models/serviceTokenData.ts +++ b/backend/src/models/serviceTokenData.ts @@ -1,3 +1,4 @@ +// TODO: deprecate import { Document, Schema, Types, model } from "mongoose"; export interface IServiceTokenData extends Document { diff --git a/backend/src/models/serviceTokenDataV3.ts b/backend/src/models/serviceTokenDataV3.ts new file mode 100644 index 000000000..a9758422e --- /dev/null +++ b/backend/src/models/serviceTokenDataV3.ts @@ -0,0 +1,129 @@ +import { Document, Schema, Types, model } from "mongoose"; +import { IPType } from "../ee/models"; + +export enum Permission { + READ = "read", + WRITE = "write" +} + +export interface IServiceTokenV3Scope { + environment: string; + secretPath: string; + permissions: Permission[]; +} + +export interface IServiceTokenV3TrustedIp { + ipAddress: string; + type: IPType; + prefix: number; +} + +export interface IServiceTokenDataV3 extends Document { + _id: Types.ObjectId; + name: string; + workspace: Types.ObjectId; + user: Types.ObjectId; + publicKey: string; + isActive: boolean; + lastUsed?: Date; + usageCount: number; + expiresAt?: Date; + scopes: Array; + trustedIps: Array; +} + +const serviceTokenDataV3Schema = new Schema( + { + name: { + type: String, + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + user: { + type: Schema.Types.ObjectId, + ref: "User", + required: true + }, + publicKey: { + type: String, + required: true + }, + isActive: { + type: Boolean, + required: true + }, + lastUsed: { + type: Date, + required: false + }, + usageCount: { + type: Number, + default: 0, + required: true + }, + expiresAt: { + type: Date, + required: false, + expires: 0 + }, + scopes: { + type: [ + { + environment: { + type: String, + required: true + }, + secretPath: { + type: String, + default: "/", + required: true + }, + permissions: { + type: [String], + enum: [Permission.READ, Permission.WRITE], + default: [Permission.READ], + required: true + } + } + ], + required: true + }, + trustedIps: { + type: [ + { + ipAddress: { + type: String, + required: true + }, + type: { + type: String, + enum: [ + IPType.IPV4, + IPType.IPV6 + ], + required: true + }, + prefix: { + type: Number, + required: false + } + } + ], + default: [{ + ipAddress: "0.0.0.0", + type: IPType.IPV4.toString(), + prefix: 0 + }], + required: true + } + }, + { + timestamps: true + } +); + +export const ServiceTokenDataV3 = model("ServiceTokenDataV3", serviceTokenDataV3Schema); \ No newline at end of file diff --git a/backend/src/models/serviceTokenDataV3Key.ts b/backend/src/models/serviceTokenDataV3Key.ts new file mode 100644 index 000000000..7a69abc1e --- /dev/null +++ b/backend/src/models/serviceTokenDataV3Key.ts @@ -0,0 +1,43 @@ +import { Document, Schema, Types, model } from "mongoose"; + +export interface IServiceTokenDataV3Key extends Document { + _id: Types.ObjectId; + encryptedKey: string; + nonce: string; + sender: Types.ObjectId; + serviceTokenData: Types.ObjectId; + workspace: Types.ObjectId; +} + +const serviceTokenDataV3KeySchema = new Schema( + { + encryptedKey: { + type: String, + required: true + }, + nonce: { + type: String, + required: true + }, + sender: { + type: Schema.Types.ObjectId, + ref: "User", + required: true + }, + serviceTokenData: { + type: Schema.Types.ObjectId, + ref: "ServiceTokenDataV3", + required: true, + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true, + } + }, + { + timestamps: true + } +); + +export const ServiceTokenDataV3Key = model("ServiceTokenDataV3Key", serviceTokenDataV3KeySchema); \ No newline at end of file diff --git a/backend/src/routes/v3/index.ts b/backend/src/routes/v3/index.ts index f4fcfe55b..1a95439ab 100644 --- a/backend/src/routes/v3/index.ts +++ b/backend/src/routes/v3/index.ts @@ -7,5 +7,5 @@ export { auth, secrets, signup, - workspaces, + workspaces } diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index 0c4aff6a0..960094f87 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -7,7 +7,7 @@ import { AuthMode } from "../../variables"; router.get( "/raw", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), secretsController.getSecretsRaw ); @@ -15,7 +15,7 @@ router.get( router.get( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -29,7 +29,7 @@ router.get( router.post( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -43,7 +43,7 @@ router.post( router.patch( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -57,7 +57,7 @@ router.patch( router.delete( "/raw/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -71,7 +71,7 @@ router.delete( router.get( "/", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -116,7 +116,7 @@ router.delete( router.post( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -127,7 +127,7 @@ router.post( router.get( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "query" @@ -138,7 +138,7 @@ router.get( router.patch( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" @@ -149,7 +149,7 @@ router.patch( router.delete( "/:secretName", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN] + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN, AuthMode.SERVICE_TOKEN_V3] }), requireBlindIndicesEnabled({ locationWorkspaceId: "body" diff --git a/backend/src/routes/v3/workspaces.ts b/backend/src/routes/v3/workspaces.ts index 834d54cd1..dcae733fc 100644 --- a/backend/src/routes/v3/workspaces.ts +++ b/backend/src/routes/v3/workspaces.ts @@ -34,4 +34,12 @@ router.post( // -- +router.get( + "/:workspaceId/service-token", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspacesController.getWorkspaceServiceTokenData +); + export default router; diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index 49a77cef6..6f46824d4 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -8,6 +8,7 @@ import { Organization, ServiceAccount, ServiceTokenData, + ServiceTokenDataV3, User } from "../models"; import { createToken } from "../helpers/auth"; @@ -54,6 +55,10 @@ const getAuthDataPayloadIdObj = (authData: AuthData) => { if (authData.authPayload instanceof ServiceTokenData) { return { serviceTokenDataId: authData.authPayload._id }; } + + if (authData.authPayload instanceof ServiceTokenDataV3) { + return { serviceTokenDataId: authData.authPayload._id }; + } }; /** @@ -62,7 +67,6 @@ const getAuthDataPayloadIdObj = (authData: AuthData) => { * @returns */ const getAuthDataPayloadUserObj = (authData: AuthData) => { - if (authData.authPayload instanceof User) { return { user: authData.authPayload._id }; } @@ -72,7 +76,11 @@ const getAuthDataPayloadUserObj = (authData: AuthData) => { } if (authData.authPayload instanceof ServiceTokenData) { - return { user: authData.authPayload.user };0 + return { user: authData.authPayload.user }; + } + + if (authData.authPayload instanceof ServiceTokenDataV3) { + return { user: authData.authPayload.user }; } } diff --git a/backend/src/utils/ip/ip.ts b/backend/src/utils/ip/ip.ts index ac3b17149..bc314fa9a 100644 --- a/backend/src/utils/ip/ip.ts +++ b/backend/src/utils/ip/ip.ts @@ -1,6 +1,6 @@ import net from "net"; import { IPType } from "../../ee/models"; -import { InternalServerError } from "../errors"; +import { InternalServerError, UnauthorizedRequestError } from "../errors"; /** * Return details of IP [ip]: @@ -98,4 +98,39 @@ export const isValidIpOrCidr = (ip: string): boolean => { } return false; -} \ No newline at end of file +} + +/** + * Validates the IP address [ipAddress] against the trusted IPs [trustedIps]. + * @param {Object} obj + * @param {String} obj.ipAddress - IP address to check + * @param {Object[]} obj.trustedIps - IPs to trust in blocklist + */ +export const checkIPAgainstBlocklist = ({ + ipAddress, + trustedIps +}: { + ipAddress: string; + trustedIps: { + ipAddress: string; + type: IPType; + prefix: number; + }[] +}) => { + const blockList = new net.BlockList(); + + for (const trustedIp of trustedIps) { + if (trustedIp.prefix !== undefined) { + blockList.addSubnet(trustedIp.ipAddress, trustedIp.prefix, trustedIp.type); + } else { + blockList.addAddress(trustedIp.ipAddress, trustedIp.type); + } + } + + const { type } = extractIPDetails(ipAddress); + const check = blockList.check(ipAddress, type); + + if (!check) throw UnauthorizedRequestError({ + message: "Failed to authenticate" + }); +} diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts index 85899a535..75552682c 100644 --- a/backend/src/validation/index.ts +++ b/backend/src/validation/index.ts @@ -10,3 +10,4 @@ export * from "./organization"; export * from "./secrets"; export * from "./serviceAccount"; export * from "./serviceTokenData"; +export * from "./serviceTokenDataV3"; diff --git a/backend/src/validation/integration.ts b/backend/src/validation/integration.ts index 20c02efc5..98dadb924 100644 --- a/backend/src/validation/integration.ts +++ b/backend/src/validation/integration.ts @@ -58,6 +58,10 @@ export const validateClientForIntegration = async ({ throw UnauthorizedRequestError({ message: "Failed service token authorization for integration" }); + case ActorType.SERVICE_V3: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for integration" + }); } }; diff --git a/backend/src/validation/integrationAuth.ts b/backend/src/validation/integrationAuth.ts index feeee4931..65eee4477 100644 --- a/backend/src/validation/integrationAuth.ts +++ b/backend/src/validation/integrationAuth.ts @@ -58,6 +58,10 @@ const validateClientForIntegrationAuth = async ({ throw UnauthorizedRequestError({ message: "Failed service token authorization for integration authorization" }); + case ActorType.SERVICE_V3: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for integration authorization" + }); } }; diff --git a/backend/src/validation/organization.ts b/backend/src/validation/organization.ts index 9aa13cbb6..ce93db9b0 100644 --- a/backend/src/validation/organization.ts +++ b/backend/src/validation/organization.ts @@ -46,6 +46,10 @@ export const validateClientForOrganization = async ({ throw UnauthorizedRequestError({ message: "Failed service token authorization for organization" }); + case ActorType.SERVICE_V3: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for organization" + }); } }; diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 28ac4428b..ea9a24e6c 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -260,7 +260,7 @@ export const CreateSecretRawV3 = z.object({ secretValue: z .string() .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), - secretComment: z.string().trim(), + secretComment: z.string().trim().optional().default(""), skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) }), diff --git a/backend/src/validation/serviceTokenDataV3.ts b/backend/src/validation/serviceTokenDataV3.ts new file mode 100644 index 000000000..976f1a1e5 --- /dev/null +++ b/backend/src/validation/serviceTokenDataV3.ts @@ -0,0 +1,119 @@ +import { Types } from "mongoose"; +import { IServiceTokenDataV3 } from "../models"; +import { Permission } from "../models/serviceTokenDataV3"; +import { z } from "zod"; +import { UnauthorizedRequestError } from "../utils/errors"; +import { isValidScopeV3 } from "../helpers"; +import { AuthData } from "../interfaces/middleware"; +import { checkIPAgainstBlocklist } from "../utils/ip"; + +/** + * 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[]} acceptedPermissions - accepted permissions as part of the endpoint + */ + export const validateServiceTokenDataV3ClientForWorkspace = async ({ + authData, + serviceTokenData, + workspaceId, + environment, + secretPath = "/", + requiredPermissions +}: { + authData: AuthData; + serviceTokenData: IServiceTokenDataV3; + workspaceId: Types.ObjectId; + environment?: string; + secretPath?: string; + requiredPermissions: Permission[]; +}) => { + + // validate ST V3 IP address + checkIPAgainstBlocklist({ + ipAddress: authData.ipAddress, + trustedIps: serviceTokenData.trustedIps + }); + + if (!serviceTokenData.workspace.equals(workspaceId)) { + // case: invalid workspaceId passed + throw UnauthorizedRequestError({ + message: "Failed service token authorization for the given workspace" + }); + } + + if (environment) { + const isValid = isValidScopeV3({ + authPayload: serviceTokenData, + environment, + secretPath, + requiredPermissions + }); + + if (!isValid) throw UnauthorizedRequestError({ + message: "Failed service token authorization for the given workspace" + }); + } +}; + +export const CreateServiceTokenV3 = z.object({ + body: z.object({ + name: z.string().trim(), + workspaceId: z.string().trim(), + publicKey: z.string().trim(), + scopes: z + .object({ + permissions: z.enum(["read", "write"]).array(), + environment: z.string().trim(), + secretPath: z.string().trim() + }) + .array() + .min(1), + trustedIps: z + .object({ + ipAddress: z.string().trim(), + }) + .array() + .min(1), + expiresIn: z.number().optional(), + encryptedKey: z.string().trim(), + nonce: z.string().trim() + }) +}); + +export const UpdateServiceTokenV3 = z.object({ + params: z.object({ + serviceTokenDataId: z.string() + }), + body: z.object({ + name: z.string().trim().optional(), + isActive: z.boolean().optional(), + scopes: z + .object({ + permissions: z.enum(["read", "write"]).array(), + environment: z.string().trim(), + secretPath: z.string().trim() + }) + .array() + .min(1) + .optional(), + trustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + expiresIn: z.number().optional() + }), +}); + +export const DeleteServiceTokenV3 = z.object({ + params: z.object({ + serviceTokenDataId: z.string() + }), +}); \ No newline at end of file diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index 9d645f5d3..5c4ae25c7 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -7,6 +7,7 @@ import { WorkspaceNotFoundError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; import { z } from "zod"; import { EventType, UserAgentType } from "../ee/models"; +import { UnauthorizedRequestError } from "../utils/errors"; /** * Validate authenticated clients for workspace with id [workspaceId] based @@ -57,8 +58,11 @@ export const validateClientForWorkspace = async ({ environment, requiredPermissions }); - - return {}; + return { membership, workspace}; + case ActorType.SERVICE_V3: + throw UnauthorizedRequestError({ + message: "Failed service token authorization for organization" + }); } }; @@ -300,3 +304,9 @@ export const NameWorkspaceSecretsV3 = z.object({ .array() }) }); + +export const GetWorkspaceServiceTokenDataV3 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); \ No newline at end of file diff --git a/backend/src/variables/authentication.ts b/backend/src/variables/authentication.ts index be2f35c81..1376c977e 100644 --- a/backend/src/variables/authentication.ts +++ b/backend/src/variables/authentication.ts @@ -1,6 +1,7 @@ export enum AuthMode { JWT = "jwt", SERVICE_TOKEN = "serviceToken", + SERVICE_TOKEN_V3 = "serviceTokenV3", API_KEY = "apiKey" } diff --git a/docs/documentation/platform/token.mdx b/docs/documentation/platform/token.mdx index 1371d4621..8be86be7f 100644 --- a/docs/documentation/platform/token.mdx +++ b/docs/documentation/platform/token.mdx @@ -3,30 +3,63 @@ title: "Service token" description: "Infisical service tokens allows you to programmatically interact with Infisical" --- -Service tokens play an integral role in allowing programmatic interactions with an Infisical project, functioning as digital token that open access to specific project resources such as secrets. +Service tokens are authentication credentials that services can use to access designated endpoints in the Infisical API to manage project resources like secrets. +Each service token can be provisioned scoped access to select environment(s) and path(s) within them. -When you generate a service token, you can define its access level, not only by specifying the paths and environments it can interact with, but also by determining the level of mutation it can perform, such as read-only, write, or both. +## Service Tokens -This level of control not only ensures maximum flexibility but also significantly enhances security as it allows you to define fine grained access to project resources. +You can manage service tokens in Project Settings > Service Tokens. +### Service Token (Current) + +Service Token (ST) is the current widely-used authentication method for managing secrets. + + + We're soon releasing ST V3, a revised version of this Service Token, so stay tuned. + + +Here's a few pointers to get you acquainted with it: + +- When you create a ST, you get a token prefixed with `st`. The part after the last `.` delimiter is a symmetric key; everything +before it is an access token. When authenticating with the Infisical API, it is important to send in only the access token portion +of the token. +- ST supports expiration; it gets deleted automatically upon expiration. +- ST supports provisioning `read` and/or `write` permissions broadly applied to all accessible environment(s) and path(s). +- ST is not editable. ## Creating a service token -To generate the token, head over to your project settings as shown below. On creating a service token you can scope it to a path to limit the access. +To create a service token, head to Project Settings > Service Tokens as shown below and press **Create token**. -![token add](../../images/project-token-add.png) +![token add](../../images/project-token-old-add.png) -### Service token permissions -![token add](../../images/service-token-permissions.png) +Now input any token configuration details such as which environment(s) and path(s) you'd like to provision +the token access to. Here's some guidance for each field: +- Name: A friendly name for the token. +- Scopes: The environment(s) and path(s) the token should have access to. +- Permissions: You can indicate whether or not the token should have `read/write` access to the paths. +Also, note that Infisical supports [glob patterns](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/) when defining access scopes to path(s). +- Expiration: The time when this token should be rendered inactive. -Service tokens can be scoped to multiple environments and paths. To add a new permission, choose the environment you want to give access to and then choose the path you'd like to give access to within that environment. +![token add](../../images/project-token-old-permissions.png) -Permissions for paths are powered by [Glob pattern](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/). This means you can create advanced folder permissions with a simple Glob patterns. +In the above screenshot, you can see that we are creating a token token with `read` access to all subfolders at any depth +of the `/common` path within the development environment of the project; the token expires in 6 months and can be used from any IP address. -**Examples of common Glob pattens** +**FAQ** - + + + There are a few reasons for why this might happen: + + - The service token has expired. + - The service token is insufficently permissioned to interact with the secrets in the given environment and path. + - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. + - (If using ST V3) The service token has not been activated yet. + - (If using ST V3) The service token is being used from an untrusted IP. + + 1. `/**`: This pattern matches all folders at any depth in the directory structure. For example, it would match folders like `/folder1/`, `/folder1/subfolder/`, and so on. 2. `/*`: This pattern matches all immediate subfolders in the current directory. It does not match any folders at a deeper level. For example, it would match folders like `/folder1/`, `/folder2/`, but not `/folder1/subfolder/`. @@ -35,3 +68,4 @@ Permissions for paths are powered by [Glob pattern](https://www.malikbrowne.com/ 4. `/folder1/*`: This pattern matches all immediate subfolders within the `/folder1/` directory. It does not match any folders outside of `/folder1/`, nor does it match any subfolders within those immediate subfolders. For example, it would match folders like `/folder1/subfolder1/`, `/folder1/subfolder2/`, but not `/folder2/subfolder/`. + \ No newline at end of file diff --git a/docs/documentation/platform/token3.mdx b/docs/documentation/platform/token3.mdx new file mode 100644 index 000000000..a04c9b853 --- /dev/null +++ b/docs/documentation/platform/token3.mdx @@ -0,0 +1,105 @@ +--- +title: "Service token" +description: "Infisical service tokens allows you to programmatically interact with Infisical" +--- + +Service tokens are authentication credentials that services can use to access designated endpoints in the Infisical API to manage project resources like secrets. +Each service token can be provisioned scoped access to select environment(s) and path(s) within them. + +## Service Tokens + +Infisical currently offers Service Token V3 and Service Token; you can manage both types of tokens in Project Settings > Service Tokens. + +### Service Token V3 (Beta) + +Service Token V3 (ST V3) is a new and improved authentication method that is in beta. + + + Currently, the Service Token V3 authentication method can only be used with the latest [Node SDK](https://github.com/Infisical/infisical-node) and [Python SDK](https://github.com/Infisical/infisical-python). + You can also make an API call with it to create, read, update, or delete secrets. + + We will be releasing compatibility for it with the CLI and K8s operator in the coming month. + + That said, we recommend using ST V3 whenever possible. + + +Here's a few pointers to get you acquainted with it: + +- When you create a ST V3, you export a `JSON` file containing 3 components: `publicKey`, `privateKey`, and `serviceToken` where +`serviceToken` is a JWT token prefixed with `stv3`. The token provides access to the Infisical API and the public-private key +pairs are to support cryptographic operations for the client whenever E2EE is needed. +- ST V3 supports IP allowlisting; this means you can restrict the usage of a ST V3 to a specific IP or CIDR range. +- ST V3 supports provisioning granular `read` or `readWrite` access down to each path. +- ST V3 supports toggling on/off active states, so you can render a ST V3 inactive without deleting it. +- ST V3 supports expiration, so, if specified, a token will automatically turn inactive after a period of time. +- ST V3 tracks most recent usage; it also keeps track of each token's usage count. +- ST V3 is editable. + +### Service Token (Current) + +Service Token (ST) is the current widely-used authentication method. + + + We recently released ST V3, a revised version of this Service Token, which you can read about above. + + Whenever possible, you should use ST V3 because we will be deprecating ST sometime Q4 2023. + + +Here's a few pointers to get you acquainted with it: + +- When you create a ST, you get a token prefixed with `st`. The part after the last `.` delimiter is a symmetric key; everything +before it is an access token. When authenticating with the Infisical API, it is important to send in only the access token portion +of the token. +- ST supports expiration; it gets deleted automatically upon expiration. +- ST supports provisioning `read` and/or `write` permissions broadly applied to all accessible environment(s) and path(s). +- ST is not editable. + +## Creating a service token + +To create a service token, head to Project Settings > Service Tokens as shown below and press **Create token**. + +![token add](../../images/project-token-add.png) + +Now input any token configuration details such as which environment(s) and path(s) you'd like to provision +the token access to. Here's some guidance for each field: + +- Name: A friendly name for the token. +- Scopes: The environment(s) and path(s) the token should have access to. +If using ST V3, you can also indicate whether or not the token should have `read` or `readWrite` access to each path. +Also, note that Infisical supports [glob patterns](https://www.malikbrowne.com/blog/a-beginners-guide-glob-patterns/) when defining access scopes to path(s). +- Trusted IPs: The IPs or CIDR ranges that the token can be used from. By default, each token is given the `0.0.0.0/0` entry representing all possible IPv4 addresses. +- Expiration: The time when this token should be rendered inactive. + + + Restricting token usage to specific trusted IPs is a paid feature. + + If you’re using Infisical Cloud, then it is available under the Pro Tier. If you’re self-hosting Infisical, then you should contact team@infisical.com to purchase an enterprise license to use it. + + +![token add](../../images/project-token-permissions.png) + +In the above screenshot, you can see that we are creating a token token with `read` access to all subfolders at any depth +of the `/common` path within the development environment of the project; the token expires in 6 months and can be used from any IP address. + +**FAQ** + + + + There are a few reasons for why this might happen: + + - The service token has expired. + - The service token is insufficently permissioned to interact with the secrets in the given environment and path. + - You are attempting to access a `/raw` secrets endpoint that requires your project to disable E2EE. + - (If using ST V3) The service token has not been activated yet. + - (If using ST V3) The service token is being used from an untrusted IP. + + + 1. `/**`: This pattern matches all folders at any depth in the directory structure. For example, it would match folders like `/folder1/`, `/folder1/subfolder/`, and so on. + + 2. `/*`: This pattern matches all immediate subfolders in the current directory. It does not match any folders at a deeper level. For example, it would match folders like `/folder1/`, `/folder2/`, but not `/folder1/subfolder/`. + + 3. `/*/*`: This pattern matches all subfolders at a depth of two levels in the current directory. It does not match any folders at a shallower or deeper level. For example, it would match folders like `/folder1/subfolder/`, `/folder2/subfolder/`, but not `/folder1/` or `/folder1/subfolder/subsubfolder/`. + + 4. `/folder1/*`: This pattern matches all immediate subfolders within the `/folder1/` directory. It does not match any folders outside of `/folder1/`, nor does it match any subfolders within those immediate subfolders. For example, it would match folders like `/folder1/subfolder1/`, `/folder1/subfolder2/`, but not `/folder2/subfolder/`. + + \ No newline at end of file diff --git a/docs/images/project-token-add.png b/docs/images/project-token-add.png index f282399e6..111282c42 100644 Binary files a/docs/images/project-token-add.png and b/docs/images/project-token-add.png differ diff --git a/docs/images/project-token-added.png b/docs/images/project-token-added.png deleted file mode 100644 index fb47f3525..000000000 Binary files a/docs/images/project-token-added.png and /dev/null differ diff --git a/docs/images/project-token-name.png b/docs/images/project-token-name.png deleted file mode 100644 index 662fbb0f7..000000000 Binary files a/docs/images/project-token-name.png and /dev/null differ diff --git a/docs/images/project-token-old-add.png b/docs/images/project-token-old-add.png new file mode 100644 index 000000000..960013a3f Binary files /dev/null and b/docs/images/project-token-old-add.png differ diff --git a/docs/images/project-token-old-permissions.png b/docs/images/project-token-old-permissions.png new file mode 100644 index 000000000..11c91b77c Binary files /dev/null and b/docs/images/project-token-old-permissions.png differ diff --git a/docs/images/project-token-permissions.png b/docs/images/project-token-permissions.png new file mode 100644 index 000000000..91de75ec4 Binary files /dev/null and b/docs/images/project-token-permissions.png differ diff --git a/docs/images/service-token-permissions.png b/docs/images/service-token-permissions.png deleted file mode 100644 index 16ecaa939..000000000 Binary files a/docs/images/service-token-permissions.png and /dev/null differ diff --git a/docs/internals/service-tokens-new.mdx b/docs/internals/service-tokens-new.mdx new file mode 100644 index 000000000..4fc8a29cf --- /dev/null +++ b/docs/internals/service-tokens-new.mdx @@ -0,0 +1,72 @@ +--- +title: "Service tokens" +description: "Understanding service tokens and their best practices" +--- +​ +Many clients use service tokens to authenticate and read/write secrets from/to Infisical; they can be created in your project settings. + +On this page, we discuss Service Token V3, the new and improved authentication method. + +## Anatomy + +A service token in Infisical exports a `JSON` file containing 3 components: `publicKey`, `privateKey`, and `serviceToken` where +`serviceToken` is a JWT token prefixed with `proj_token`. The token provides access to the Infisical API and the public-private key +pairs are to support cryptographic operations for the client whenever E2EE is needed. + +### Database model + +The storage backend model for a token contains the following information: + +- ID: The token identifier. +- Expiration: The date at which point the token is invalid. +- Project: The project that the token is part of. +- Status: The active/inactive state of a token. +- Scopes: The project environment(s) and path(s) that the token has access to as well as `read` or `readWrite` permissions for them. +- Trusted IPs: The specific (IPv4 or IPv6) IPs or CIDR ranges that the token can be used from. +- Last used: The date at which point the token was last used. +- Usage count: The number of times that the token has been used. + +### Token + +As mentioned before, a service token consists of three components, exported as a `JSON`, used for authentication and cryptographic purposes. + +Consider the following `JSON`: + +``` +{ + "publicKey": "...", + "privateKey": "...", + "serviceToken": "stv3..." +} +``` + +Here, the `serviceToken` component can be used to authenticate with the API, by including it in the `Authorization` header under `Bearer ` and retrieve (encrypted) secrets as well as a project key back. Meanwhile, the `privateKey` (in the `JSON`), and `publicKey` (returned in the encrypted project key response) can be used to decrypt the project key used to decrypt the secrets. + +Note that when using service tokens via select client methods like SDK or CLI, cryptographic operations are abstracted for you that is the token is parsed and encryption/decryption operations are handled. If using service tokens with the REST API and end-to-end encryption enabled, then you will have to handle the encryption/decryption operations yourself. +​ +## Recommendations + +### Permissions + +You should consider the [principle of least privilege(PoLP)](https://en.wikipedia.org/wiki/Principle_of_least_privilege) when setting which environment(s) and path(s) +should be accessible by a service token; you should also consider whether or not it needs `read` or `readWrite` access. + +For example, if the client using the token only requires `read` access to the secrets in the `/config` path of the staging environment, then you should scope the token to the `/config` path of that environment only with `read` permission. + +### Status & Expiration + +We recommend considering whether or not a service token should be able to access secrets indefinitely or within a finite lifetime such as until 6 months or 1 year from now + +### Network access + +We recommend configuring the IP allowlist configuration of each service token to restrict its usage to specific IP addresses or CIDR-notated range of addresses. + +### Storage + +Since service tokens grant access to your secrets, we recommend storing them securely across your development cycle whether it be in a .env file in local development or as an environment variable of your deployment platform. + +### Rotation + +We recommend periodically rotating the service token, even in the absence of compromise. Since service tokens are capable of decrypting project keys used to decrypt secrets, they should be rotated before approximately 2^32 encryptions have been performed; this follows the guidance set forth by [NIST publication 800-38D](https://csrc.nist.gov/pubs/sp/800/38/d/final). + +Note that Infisical keeps track of the number of times that service tokens are used and will alert you when you have reached 90% of the recommended capacity. \ No newline at end of file diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index ae1e7fd4a..98a4ad71a 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -29,7 +29,7 @@ export const ModalContent = forwardRef( diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index cfdee0a10..9d31f812d 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -16,6 +16,9 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.DELETE_TRUSTED_IP]: "Delete trusted IP", [EventType.CREATE_SERVICE_TOKEN]: "Create service token", [EventType.DELETE_SERVICE_TOKEN]: "Delete service token", + [EventType.CREATE_SERVICE_TOKEN_V3]: "Create (new) service token", + [EventType.UPDATE_SERVICE_TOKEN_V3]: "Update (new) service token", + [EventType.DELETE_SERVICE_TOKEN_V3]: "Delete (new) service token", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.DELETE_ENVIRONMENT]: "Delete environment", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index d19876525..c292fdc24 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -1,6 +1,7 @@ export enum ActorType { USER = "user", - SERVICE = "service" + SERVICE = "service", + SERVICE_V3 = "service-v3" } export enum UserAgentType { @@ -24,8 +25,11 @@ export enum EventType { ADD_TRUSTED_IP = "add-trusted-ip", UPDATE_TRUSTED_IP = "update-trusted-ip", DELETE_TRUSTED_IP = "delete-trusted-ip", - CREATE_SERVICE_TOKEN = "create-service-token", - DELETE_SERVICE_TOKEN = "delete-service-token", + CREATE_SERVICE_TOKEN = "create-service-token", // v2 + DELETE_SERVICE_TOKEN = "delete-service-token", // v2 + CREATE_SERVICE_TOKEN_V3 = "create-service-token-v3", // v3 + UPDATE_SERVICE_TOKEN_V3 = "update-service-token-v3", // v3 + DELETE_SERVICE_TOKEN_V3 = "delete-service-token-v3", // v3 CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 40488f196..f28f9004c 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -4,6 +4,17 @@ import { UserAgentType } from "./enums"; +enum Permission { + READ = "read", + READ_WRITE = "readWrite" +} + +interface Scope { + environment: string; + secretPath: string; + permission: Permission; +} + interface UserActorMetadata { userId: string; email: string; @@ -14,7 +25,6 @@ interface ServiceActorMetadata { name: string; } - interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -25,9 +35,15 @@ export interface ServiceActor { metadata: ServiceActorMetadata; } +export interface ServiceActorV3 { + type: ActorType.SERVICE_V3; + metadata: ServiceActorMetadata; +} + export type Actor = | UserActor - | ServiceActor; + | ServiceActor + | ServiceActorV3; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -190,6 +206,36 @@ interface DeleteServiceTokenEvent { } } +interface CreateServiceTokenV3Event { + type: EventType.CREATE_SERVICE_TOKEN_V3; + metadata: { + name: string; + isActive: boolean; + scopes: Array; + expiresAt?: Date; + } +} + +interface UpdateServiceTokenV3Event { + type: EventType.UPDATE_SERVICE_TOKEN_V3; + metadata: { + name?: string; + isActive?: boolean; + scopes?: Array; + expiresAt?: Date; + } +} + +interface DeleteServiceTokenV3Event { + type: EventType.DELETE_SERVICE_TOKEN_V3; + metadata: { + name: string; + isActive: boolean; + scopes: Array; + expiresAt?: Date; + } +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -387,6 +433,9 @@ export type Event = | DeleteTrustedIPEvent | CreateServiceTokenEvent | DeleteServiceTokenEvent + | CreateServiceTokenV3Event + | UpdateServiceTokenV3Event + | DeleteServiceTokenV3Event | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/frontend/src/hooks/api/serviceTokens/enums.ts b/frontend/src/hooks/api/serviceTokens/enums.ts new file mode 100644 index 000000000..317bdb0f6 --- /dev/null +++ b/frontend/src/hooks/api/serviceTokens/enums.ts @@ -0,0 +1,4 @@ +export enum Permission { + READ = "read", + WRITE = "write" +} \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceTokens/index.ts b/frontend/src/hooks/api/serviceTokens/index.ts index b594d5a64..2ae762611 100644 --- a/frontend/src/hooks/api/serviceTokens/index.ts +++ b/frontend/src/hooks/api/serviceTokens/index.ts @@ -1 +1,7 @@ -export { useCreateServiceToken, useDeleteServiceToken, useGetUserWsServiceTokens } from "./queries"; +export { + useCreateServiceToken, + useCreateServiceTokenV3, + useDeleteServiceToken, + useDeleteServiceTokenV3, + useGetUserWsServiceTokens, + useUpdateServiceTokenV3} from "./queries"; diff --git a/frontend/src/hooks/api/serviceTokens/queries.tsx b/frontend/src/hooks/api/serviceTokens/queries.tsx index 4a0241cdf..900bb1d34 100644 --- a/frontend/src/hooks/api/serviceTokens/queries.tsx +++ b/frontend/src/hooks/api/serviceTokens/queries.tsx @@ -2,12 +2,17 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { workspaceKeys } from "../workspace/queries"; import { + CreateServiceTokenDataV3DTO, + CreateServiceTokenDataV3Res, CreateServiceTokenDTO, CreateServiceTokenRes, + DeleteServiceTokenDataV3DTO, DeleteServiceTokenRes, - ServiceToken -} from "./types"; + ServiceToken, + ServiceTokenDataV3, + UpdateServiceTokenDataV3DTO} from "./types"; const serviceTokenKeys = { getAllWorkspaceServiceToken: (workspaceID: string) => [{ workspaceID }, "service-tokens"] as const @@ -32,12 +37,11 @@ export const useGetUserWsServiceTokens = ({ workspaceID }: UseGetWorkspaceServic } // mutation -export const useCreateServiceToken = () => { +export const useCreateServiceToken = () => { // TODO: deprecate const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { - console.log("useCreateServiceToken"); const { data } = await apiRequest.post("/api/v2/service-token/", body); data.serviceToken += `.${body.randomBytes}`; return data; @@ -53,7 +57,6 @@ export const useDeleteServiceToken = () => { return useMutation({ mutationFn: async (serviceTokenId) => { - console.log("useDeleteServiceToken"); const { data } = await apiRequest.delete(`/api/v2/service-token/${serviceTokenId}`); return data; }, @@ -62,3 +65,58 @@ export const useDeleteServiceToken = () => { } }); }; + +export const useCreateServiceTokenV3 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post("/api/v3/service-token/", body); + return data; + }, + onSuccess: ({ serviceTokenData }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(serviceTokenData.workspace)); + } + }); +}; + +export const useUpdateServiceTokenV3 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + serviceTokenDataId, + name, + isActive, + scopes, + trustedIps, + expiresIn + }) => { + const { data: { serviceTokenData } } = await apiRequest.patch(`/api/v3/service-token/${serviceTokenDataId}`, { + name, + isActive, + scopes, + trustedIps, + expiresIn + }); + + return serviceTokenData; + }, + onSuccess: ({ workspace }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(workspace)); + } + }); +}; + +export const useDeleteServiceTokenV3 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + serviceTokenDataId + }) => { + const { data: { serviceTokenData } } = await apiRequest.delete(`/api/v3/service-token/${serviceTokenDataId}`); + return serviceTokenData; + }, + onSuccess: ({ workspace }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceServiceTokenDataV3(workspace)); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceTokens/types.ts b/frontend/src/hooks/api/serviceTokens/types.ts index d6dc322cc..d47c3bc4c 100644 --- a/frontend/src/hooks/api/serviceTokens/types.ts +++ b/frontend/src/hooks/api/serviceTokens/types.ts @@ -1,3 +1,5 @@ +import { Permission } from "./enums"; + export type ServiceTokenScope = { environment: string; secretPath: string; @@ -33,3 +35,65 @@ export type CreateServiceTokenRes = { }; export type DeleteServiceTokenRes = { serviceTokenData: ServiceToken }; + +// --- v3 + +export type ServiceTokenV3Scope = { + permissions: Permission[]; + environment: string; + secretPath: string; +}; + +export type ServiceTokenV3TrustedIp = { + _id: string; + ipAddress: string; + type: "ipv4" | "ipv6"; + prefix?: number; +} + +export type ServiceTokenDataV3 = { + _id: string; + name: string; + workspace: string; + isActive: boolean; + lastUsed?: string; + usageCount: number; + scopes: ServiceTokenV3Scope[]; + trustedIps: ServiceTokenV3TrustedIp[]; + expiresAt?: string; + createdAt: string; + updatedAt: string; +}; + +export type CreateServiceTokenDataV3DTO = { + name: string; + workspaceId: string; + publicKey: string; + scopes: ServiceTokenV3Scope[]; + trustedIps: { + ipAddress: string; + }[]; + expiresIn?: number; + encryptedKey: string; + nonce: string; +} + +export type CreateServiceTokenDataV3Res = { + serviceToken: string; + serviceTokenData: ServiceTokenDataV3; +} + +export type UpdateServiceTokenDataV3DTO = { + serviceTokenDataId: string; + isActive?: boolean; + name?: string; + scopes?: ServiceTokenV3Scope[]; + trustedIps?: { + ipAddress: string; + }[]; + expiresIn?: number; +} + +export type DeleteServiceTokenDataV3DTO = { + serviceTokenDataId: string; +} \ No newline at end of file diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index fe63c2ad2..f7b749156 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -13,6 +13,7 @@ export { useGetWorkspaceIndexStatus, useGetWorkspaceIntegrations, useGetWorkspaceSecrets, + useGetWorkspaceServiceTokenDataV3, useGetWorkspaceUsers, useNameWorkspaceSecrets, useRenameWorkspace, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index f9e40d2db..e3136fae3 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; import { EncryptedSecret } from "../secrets/types"; +import { ServiceTokenDataV3 } from "../serviceTokens/types"; import { TWorkspaceUser } from "../users/types"; import { CreateEnvironmentDTO, @@ -32,7 +33,8 @@ export const workspaceKeys = { getAllUserWorkspace: ["workspaces"] as const, getUserWsEnvironments: (workspaceId: string) => ["workspace-env", { workspaceId }] as const, getWorkspaceAuditLogs: (workspaceId: string) => [{ workspaceId }] as const, - getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }] as const + getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }] as const, + getWorkspaceServiceTokenDataV3: (workspaceId: string) => [{ workspaceId }, "workspace-service-token-data-v3"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -361,3 +363,19 @@ export const useUpdateUserWorkspaceRole = () => { } }); }; + +export const useGetWorkspaceServiceTokenDataV3 = (workspaceId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceServiceTokenDataV3(workspaceId), + queryFn: async () => { + const { + data: { serviceTokenData } + } = await apiRequest.get<{ serviceTokenData: ServiceTokenDataV3[] }>( + `/api/v3/workspaces/${workspaceId}/service-token` + ); + + return serviceTokenData; + }, + enabled: true + }); +}; \ No newline at end of file diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx index 43bb1e38f..ddc271ef0 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx @@ -50,6 +50,12 @@ export const LogsFilter = ({ {actor.metadata.name} ); + case ActorType.SERVICE_V3: + return ( + + {actor.metadata.name} + + ); default: return ( diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx index 6c2bdc710..f6d38bf7c 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTable.tsx @@ -93,6 +93,4 @@ export const LogsTable = ({ )} ); -} - -// TODO: retrieve count \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index 0520d73ba..52f35393a 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -29,6 +29,13 @@ export const LogsTableRow = ({

Service token

); + case ActorType.SERVICE_V3: + return ( + +

{`${actor.metadata.name}`}

+

Service token V3

+ + ); default: return ( @@ -160,6 +167,24 @@ export const LogsTableRow = ({

{`Name: ${event.metadata.name}`}

); + case EventType.CREATE_SERVICE_TOKEN_V3: + return ( + +

{`Name: ${event.metadata.name}`}

+ + ); + case EventType.UPDATE_SERVICE_TOKEN_V3: + return ( + +

{`Name: ${event.metadata.name}`}

+ + ); + case EventType.DELETE_SERVICE_TOKEN_V3: + return ( + +

{`Name: ${event.metadata.name}`}

+ + ); case EventType.CREATE_ENVIRONMENT: return ( diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/APIKeyTable.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/APIKeyTable.tsx index 40a309eb0..3c08c04cd 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/APIKeyTable.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/APIKeyTable.tsx @@ -1,5 +1,6 @@ import { faKey, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { @@ -37,68 +38,55 @@ export const APIKeyTable = () => { } }; - const formatDate = (dateToFormat: string) => { - const date = new Date(dateToFormat); - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - - const formattedDate = `${day}/${month}/${year}`; - - return formattedDate; - }; - return ( -
- - - + +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ _id, name, createdAt, expiresAt, lastUsed }) => { + return ( + + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( - - - - - - - - {isLoading && } - {!isLoading && - data && - data.length > 0 && - data.map(({ _id, name, createdAt, expiresAt, lastUsed }) => { - return ( - - - - - - - - ); - })} - {!isLoading && data && data?.length === 0 && ( - - - - )} - -
NameLast activeCreatedExpiration +
{name}{format(new Date(lastUsed), "yyyy-MM-dd")}{format(new Date(createdAt), "yyyy-MM-dd")}{format(new Date(expiresAt), "yyyy-MM-dd")} + { + await handleDeleteAPIKeyDataClick(_id); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + +
NameLast activeCreatedExpiration + + +
{name}{formatDate(lastUsed)}{formatDate(createdAt)}{formatDate(expiresAt)} - { - await handleDeleteAPIKeyDataClick(_id); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - > - - -
- -
-
-
+ )} + + + ); }; diff --git a/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/AddAPIKeyModal.tsx b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/AddAPIKeyModal.tsx index ef6008a67..e3b43f8ae 100644 --- a/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/AddAPIKeyModal.tsx +++ b/frontend/src/views/Settings/PersonalSettingsPage/APIKeySection/AddAPIKeyModal.tsx @@ -161,18 +161,18 @@ export const AddAPIKeyModal = ({ )} />
- - + +
) : ( diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index b70760d7f..8aa06594b 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -2,8 +2,6 @@ import { Fragment } from "react"; import { useTranslation } from "react-i18next"; import { Tab } from "@headlessui/react"; -import NavHeader from "@app/components/navigation/NavHeader"; - import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab"; import { WebhooksTab } from "./components/WebhooksTab"; @@ -17,12 +15,9 @@ const tabs = [ export const ProjectSettingsPage = () => { const { t } = useTranslation(); return ( -
-
-
- -
-
+
+
+

{t("settings.project.title")}

diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx index cfa325b41..d4f8e79e7 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx @@ -1,7 +1,11 @@ import { ServiceTokenSection } from "../ServiceTokenSection"; +// import { ServiceTokenV3Section } from "../ServiceTokenV3Section"; export const ProjectServiceTokensTab = () => { return ( - + <> + {/* */} + + ); } \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx index a44db233b..7910652b5 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx @@ -88,7 +88,10 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { } = useForm({ resolver: yupResolver(schema), defaultValues: { - scopes: [{ secretPath: "/", environment: currentWorkspace?.environments?.[0]?.slug }] + scopes: [{ + secretPath: "/", + environment: currentWorkspace?.environments?.[0]?.slug + }] } }); @@ -133,7 +136,7 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { plaintext: key, key: randomBytes }); - + const { serviceToken } = await createServiceToken.mutateAsync({ encryptedKey: ciphertext, iv, diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx index b560165f2..94a185871 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -50,7 +50,7 @@ export const ServiceTokenSection = withProjectPermission(

- {t("section.token.service-tokens")} + Service Tokens

{ Token Name - Envrionment - Secret Path + Environment - Secret Path Valid Until diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx new file mode 100644 index 000000000..cc3c81bab --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx @@ -0,0 +1,525 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus,faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import nacl from "tweetnacl"; +import { encodeBase64 } from "tweetnacl-util"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + decryptAssymmetric, + encryptAssymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { + Button, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem, + UpgradePlanModal +} from "@app/components/v2"; +import { + useSubscription, + useWorkspace +} from "@app/context"; +import { + useCreateServiceTokenV3, + useGetUserWsKey, + useUpdateServiceTokenV3 +} from "@app/hooks/api"; +import { + Permission +} from "@app/hooks/api/serviceTokens/enums"; +import { + ServiceTokenV3Scope, + ServiceTokenV3TrustedIp +} from "@app/hooks/api/serviceTokens/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const expirations = [ + { label: "Never", value: undefined }, + { 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" } +]; + +const permissionsMap: { + [key: string]: Permission[] +} = { + "read": [Permission.READ], + "readWrite": [Permission.READ, Permission.WRITE], +} + +const schema = yup.object({ + name: yup.string().required("ST V3 name is required"), + expiresIn: yup.string(), + scopes: yup + .array( + yup.object({ + permission: yup.string().oneOf(Object.keys(permissionsMap), "Invalid permission").required().label("Permission"), + environment: yup.string().max(50).required().label("Environment"), + secretPath: yup + .string() + .required() + .default("/") + .label("Secret Path") + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ) + }) + ) + .min(1) + .required() + .label("Scope"), + trustedIps: yup + .array( + yup.object({ + ipAddress: yup.string().max(50).required().label("IP Address") + }) + ) + .min(1) + .required() + .label("Trusted IP") +}).required(); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["serviceTokenV3", "upgradePlan"]>; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["serviceTokenV3", "upgradePlan"]>, state?: boolean) => void; +}; + +export const AddServiceTokenV3Modal = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); + const { mutateAsync: createMutateAsync } = useCreateServiceTokenV3(); + const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3(); + const { createNotification } = useNotificationContext(); + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: "", + scopes: [{ + permission: "read", + environment: currentWorkspace?.environments?.[0]?.slug, + secretPath: "/", + }], + trustedIps: [{ + ipAddress: "0.0.0.0/0" + }] + } + }); + + useEffect(() => { + const serviceTokenData = popUp?.serviceTokenV3?.data as { + serviceTokenDataId: string; + name: string; + scopes: ServiceTokenV3Scope[]; + trustedIps: ServiceTokenV3TrustedIp[]; + }; + + if (serviceTokenData) { + reset({ + name: serviceTokenData.name, + scopes: serviceTokenData.scopes.map(({ + environment, + secretPath, + permissions + }: ServiceTokenV3Scope) => { + let permission = "read"; + if (permissions.includes(Permission.WRITE)) { + permission = "readWrite"; + } + + return ({ + environment, + secretPath, + permission + }) + }), + trustedIps: serviceTokenData.trustedIps.map(({ + ipAddress, + prefix + }: ServiceTokenV3TrustedIp) => { + return ({ + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }); + }) + }); + } else { + reset({ + name: "", + scopes: [{ + permission: "read", + environment: currentWorkspace?.environments?.[0]?.slug, + secretPath: "/", + }], + trustedIps: [{ + ipAddress: "0.0.0.0/0" + }] + }); + } + }, [popUp?.serviceTokenV3?.data]); + + const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" }); + const { fields: tokenTrustedIps, append: appendTrustedIp, remove: removeTrustedIp } = useFieldArray({ control, name: "trustedIps" }); + + const onFormSubmit = async ({ + name, + expiresIn, + scopes, + trustedIps + }: FormData) => { + try { + const serviceTokenData = popUp?.serviceTokenV3?.data as { + serviceTokenDataId: string; + name: string; + scopes: any; + }; + + // convert read/readWrite permission => ["read", "write"] format + const reformattedScopes = scopes.map((scope) => { + return ({ + environment: scope.environment, + secretPath: scope.secretPath, + permissions: permissionsMap[scope.permission] + }); + }); + + if (serviceTokenData) { + // update + + await updateMutateAsync({ + serviceTokenDataId: serviceTokenData.serviceTokenDataId, + name, + scopes: reformattedScopes, + trustedIps, + expiresIn: expiresIn === "" ? undefined : Number(expiresIn) + }); + } else { + // create + if (!currentWorkspace?._id) return; + if (!latestFileKey) return; + + const pair = nacl.box.keyPair(); + const secretKeyUint8Array = pair.secretKey; + const publicKeyUint8Array = pair.publicKey; + const privateKey = encodeBase64(secretKeyUint8Array); + const publicKey = encodeBase64(publicKeyUint8Array); + + const key = decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: key, + publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string + }); + + const { serviceToken } = await createMutateAsync({ + name, + workspaceId: currentWorkspace._id, + publicKey, + scopes: reformattedScopes, + trustedIps, + expiresIn: expiresIn === "" ? undefined : Number(expiresIn), + encryptedKey: ciphertext, + nonce + }); + + const downloadData = { + publicKey, + privateKey, + serviceToken + }; + + const blob = new Blob([JSON.stringify(downloadData, null, 2)], { type: "application/json" }); + const href = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = href; + link.download = `infisical_${name}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + createNotification({ + text: `Successfully ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, + type: "success" + }); + + reset(); + handlePopUpToggle("serviceTokenV3", false); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("serviceTokenV3", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + {tokenScopes.map(({ id }, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + remove(index)} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+ {tokenTrustedIps.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> +
+ + +
+ + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx new file mode 100644 index 000000000..3b00202f9 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx @@ -0,0 +1,98 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { + useDeleteServiceTokenV3 +} from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddServiceTokenV3Modal } from "./AddServiceTokenV3Modal"; +import { ServiceTokenV3Table } from "./ServiceTokenV3Table"; + +export const ServiceTokenV3Section = withProjectPermission( + () => { + const { createNotification } = useNotificationContext(); + const { mutateAsync: deleteMutateAsync } = useDeleteServiceTokenV3(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "serviceTokenV3", + "deleteServiceTokenV3", + "upgradePlan" + ] as const); + + const onDeleteServiceTokenDataSubmit = async (serviceTokenDataId: string) => { + try { + await deleteMutateAsync({ + serviceTokenDataId + }); + createNotification({ + text: "Successfully deleted service token v3", + type: "success" + }); + + handlePopUpClose("deleteServiceTokenV3"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete service token v3", + type: "error" + }); + } + } + + return ( +
+
+

+ Service Tokens V3 (Beta) +

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteServiceTokenV3", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteServiceTokenDataSubmit( + (popUp?.deleteServiceTokenV3?.data as { serviceTokenDataId: string })?.serviceTokenDataId + ) + } + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } +); \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx new file mode 100644 index 000000000..dd9268bf6 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx @@ -0,0 +1,230 @@ +import { faKey, faPencil,faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Switch, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub , useWorkspace } from "@app/context"; +import { + useGetWorkspaceServiceTokenDataV3, + useUpdateServiceTokenV3 +} from "@app/hooks/api"; +import { Permission } from "@app/hooks/api/serviceTokens/enums" +import { ServiceTokenV3Scope, ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types" +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteServiceTokenV3", "serviceTokenV3"]>, + data?: { + serviceTokenDataId?: string; + name?: string; + scopes?: ServiceTokenV3Scope[]; + trustedIps?: ServiceTokenV3TrustedIp[]; + } + ) => void; + }; + +export const ServiceTokenV3Table = ({ + handlePopUpOpen +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetWorkspaceServiceTokenDataV3(currentWorkspace?._id || ""); + const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3(); + + const handleToggleServiceTokenDataStatus = async ({ + serviceTokenDataId, + isActive + }: { + serviceTokenDataId: string; + isActive: boolean; + }) => { + try { + await updateMutateAsync({ + serviceTokenDataId, + isActive + }); + + createNotification({ + text: `Successfully ${isActive ? "enabled" : "disabled"} service token v3`, + type: "success" + }); + } catch (err) { + console.log(err); + createNotification({ + text: `Failed to ${isActive ? "enable" : "disable"} service token v3`, + type: "error" + }); + } + } + + return ( + + + + + + + + + {/* */} + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ + _id, + name, + isActive, + lastUsed, + // usageCount, + scopes, + trustedIps, + createdAt, + expiresAt + }) => { + return ( + + + + + + {/* */} + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameStatusScopesTrusted IPs# Times UsedLast UsedCreated AtExpires At +
{name} + + {(isAllowed) => ( + handleToggleServiceTokenDataStatus({ + serviceTokenDataId: _id, + isActive: value + })} + isChecked={isActive} + isDisabled={!isAllowed} + > +

{isActive ? "Active" : "Inactive"}

+
+ )} +
+
+ {scopes.map((scope) => { + let permissionText = "read" + if ( + scope.permissions.includes(Permission.WRITE) && + scope.permissions.includes(Permission.READ) + ) { + permissionText = "readWrite"; + } + + return ( +

+ + {permissionText} + + {` @${scope.environment} - ${scope.secretPath}`} +

+ ); + })} +
+ {trustedIps.map(({ + _id: trustedIpId, + ipAddress, + prefix + }) => { + return ( +

+ {`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`} +

+ ); + })} +
{usageCount}{lastUsed ? format(new Date(lastUsed), "yyyy-MM-dd") : "-"}{format(new Date(createdAt), "yyyy-MM-dd")}{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"} + + {(isAllowed) => ( + { + handlePopUpOpen("serviceTokenV3", { + serviceTokenDataId: _id, + name, + scopes, + trustedIps + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteServiceTokenV3", { + serviceTokenDataId: _id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/index.tsx new file mode 100644 index 000000000..b6abc117c --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenV3Section/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenV3Section } from "./ServiceTokenV3Section"; \ No newline at end of file