From 7aaf0f4ed3af3a1809fc7bc678fc320a058380d3 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 26 Feb 2025 23:29:19 +0900 Subject: [PATCH] feat(secret-sharing): secret requests --- .../20250226021631_secret-requests.ts | 25 ++ backend/src/db/schemas/secret-sharing.ts | 1 + .../ee/services/audit-log/audit-log-types.ts | 13 +- backend/src/server/routes/index.ts | 4 +- backend/src/server/routes/v1/index.ts | 11 +- .../routes/v1/secret-requests-router.ts | 272 ++++++++++++++++++ .../server/routes/v1/secret-sharing-router.ts | 5 +- .../resource-cleanup-queue.ts | 3 +- .../secret-sharing/secret-sharing-dal.ts | 75 ++++- .../secret-sharing/secret-sharing-service.ts | 230 ++++++++++++++- .../secret-sharing/secret-sharing-types.ts | 28 +- backend/src/services/smtp/smtp-service.ts | 3 +- .../secretRequestCompleted.handlebars | 33 +++ .../src/services/telemetry/telemetry-types.ts | 23 +- frontend/src/const.ts | 1 + frontend/src/const/routes.ts | 10 +- .../src/hooks/api/secretSharing/mutations.ts | 69 ++++- .../src/hooks/api/secretSharing/queries.ts | 48 +++- frontend/src/hooks/api/secretSharing/types.ts | 41 ++- .../SecretSharingPage/SecretSharingPage.tsx | 2 +- .../SecretSharingPage/ShareSecretSection.tsx | 75 +++++ .../RequestSecret/AddSecretRequestModal.tsx | 30 ++ .../RequestSecret/RequestSecretForm.tsx | 182 ++++++++++++ .../RequestSecret/RequestSecretTab.tsx | 97 +++++++ .../RequestSecret/RequestedSecretsRow.tsx | 122 ++++++++ .../RequestSecret/RequestedSecretsTable.tsx | 71 +++++ .../RequestSecret/RevealSecretValueModal.tsx | 73 +++++ .../{ => ShareSecret}/AddShareSecretModal.tsx | 0 .../ShareSecretTab.tsx} | 38 ++- .../{ => ShareSecret}/ShareSecretsRow.tsx | 0 .../{ => ShareSecret}/ShareSecretsTable.tsx | 0 .../organization/SecretSharingPage/route.tsx | 15 +- .../ViewSecretRequestByIDPage.tsx | 184 ++++++++++++ .../components/SecretErrorContainer.tsx | 13 + .../components/SecretRequestContainer.tsx | 80 ++++++ .../SecretRequestSuccessContainer.tsx | 23 ++ .../SecretValueAlreadySharedContainer.tsx | 18 ++ .../ViewSecretRequestByIDPage/route.tsx | 17 ++ frontend/src/routeTree.gen.ts | 28 ++ frontend/src/routes.ts | 1 + 40 files changed, 1915 insertions(+), 49 deletions(-) create mode 100644 backend/src/db/migrations/20250226021631_secret-requests.ts create mode 100644 backend/src/server/routes/v1/secret-requests-router.ts create mode 100644 backend/src/services/smtp/templates/secretRequestCompleted.handlebars create mode 100644 frontend/src/pages/organization/SecretSharingPage/ShareSecretSection.tsx create mode 100644 frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/AddSecretRequestModal.tsx create mode 100644 frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestSecretForm.tsx create mode 100644 frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestSecretTab.tsx create mode 100644 frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestedSecretsRow.tsx create mode 100644 frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestedSecretsTable.tsx create mode 100644 frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RevealSecretValueModal.tsx rename frontend/src/pages/organization/SecretSharingPage/components/{ => ShareSecret}/AddShareSecretModal.tsx (100%) rename frontend/src/pages/organization/SecretSharingPage/components/{ShareSecretSection.tsx => ShareSecret/ShareSecretTab.tsx} (72%) rename frontend/src/pages/organization/SecretSharingPage/components/{ => ShareSecret}/ShareSecretsRow.tsx (100%) rename frontend/src/pages/organization/SecretSharingPage/components/{ => ShareSecret}/ShareSecretsTable.tsx (100%) create mode 100644 frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx create mode 100644 frontend/src/pages/public/ViewSecretRequestByIDPage/components/SecretErrorContainer.tsx create mode 100644 frontend/src/pages/public/ViewSecretRequestByIDPage/components/SecretRequestContainer.tsx create mode 100644 frontend/src/pages/public/ViewSecretRequestByIDPage/components/SecretRequestSuccessContainer.tsx create mode 100644 frontend/src/pages/public/ViewSecretRequestByIDPage/components/SecretValueAlreadySharedContainer.tsx create mode 100644 frontend/src/pages/public/ViewSecretRequestByIDPage/route.tsx diff --git a/backend/src/db/migrations/20250226021631_secret-requests.ts b/backend/src/db/migrations/20250226021631_secret-requests.ts new file mode 100644 index 000000000..cac47bd88 --- /dev/null +++ b/backend/src/db/migrations/20250226021631_secret-requests.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasSharingTypeColumn = await knex.schema.hasColumn(TableName.SecretSharing, "type"); + + await knex.schema.alterTable(TableName.SecretSharing, (table) => { + if (!hasSharingTypeColumn) { + table.string("type", 32).defaultTo(SecretSharingType.Share).notNullable(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasSharingTypeColumn = await knex.schema.hasColumn(TableName.SecretSharing, "type"); + + await knex.schema.alterTable(TableName.SecretSharing, (table) => { + if (hasSharingTypeColumn) { + table.dropColumn("type"); + } + }); +} diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index d47f288b2..3406b4d63 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -12,6 +12,7 @@ import { TImmutableDBKeys } from "./models"; export const SecretSharingSchema = z.object({ id: z.string().uuid(), encryptedValue: z.string().nullable().optional(), + type: z.string(), iv: z.string().nullable().optional(), tag: z.string().nullable().optional(), hashedHex: z.string().nullable().optional(), diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index b7bd8b7d4..bafeba120 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -250,6 +250,7 @@ export enum EventType { UPDATE_APP_CONNECTION = "update-app-connection", DELETE_APP_CONNECTION = "delete-app-connection", CREATE_SHARED_SECRET = "create-shared-secret", + CREATE_SECRET_REQUEST = "create-secret-request", DELETE_SHARED_SECRET = "delete-shared-secret", READ_SHARED_SECRET = "read-shared-secret", GET_SECRET_SYNCS = "get-secret-syncs", @@ -2020,6 +2021,15 @@ interface CreateSharedSecretEvent { }; } +interface CreateSecretRequestEvent { + type: EventType.CREATE_SECRET_REQUEST; + metadata: { + id: string; + accessType: string; + name?: string; + }; +} + interface DeleteSharedSecretEvent { type: EventType.DELETE_SHARED_SECRET; metadata: { @@ -2470,4 +2480,5 @@ export type Event = | KmipOperationActivateEvent | KmipOperationRevokeEvent | KmipOperationLocateEvent - | KmipOperationRegisterEvent; + | KmipOperationRegisterEvent + | CreateSecretRequestEvent; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c1ab5d149..efc1cb865 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1096,7 +1096,9 @@ export const registerRoutes = async ( permissionService, secretSharingDAL, orgDAL, - kmsService + kmsService, + smtpService, + userDAL }); const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index e3f6c7f2e..087bd9afd 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -37,6 +37,7 @@ import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; +import { registerSecretRequestsRouter } from "./secret-requests-router"; import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSlackRouter } from "./slack-router"; @@ -110,7 +111,15 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); await server.register(registerWebhookRouter, { prefix: "/webhooks" }); await server.register(registerIdentityRouter, { prefix: "/identities" }); - await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" }); + + await server.register( + async (secretSharingRouter) => { + await secretSharingRouter.register(registerSecretSharingRouter, { prefix: "/shared" }); + await secretSharingRouter.register(registerSecretRequestsRouter, { prefix: "/requests" }); + }, + { prefix: "/secret-sharing" } + ); + await server.register(registerUserEngagementRouter, { prefix: "/user-engagement" }); await server.register(registerDashboardRouter, { prefix: "/dashboard" }); await server.register(registerCmekRouter, { prefix: "/kms" }); diff --git a/backend/src/server/routes/v1/secret-requests-router.ts b/backend/src/server/routes/v1/secret-requests-router.ts new file mode 100644 index 000000000..f3197a762 --- /dev/null +++ b/backend/src/server/routes/v1/secret-requests-router.ts @@ -0,0 +1,272 @@ +import { z } from "zod"; + +import { SecretSharingSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SecretSharingAccessType } from "@app/lib/types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +export const registerSecretRequestsRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + secretRequest: SecretSharingSchema.omit({ + encryptedSecret: true, + tag: true, + iv: true, + encryptedValue: true + }).extend({ + isSecretValueSet: z.boolean(), + requester: z.object({ + organizationName: z.string(), + firstName: z.string().nullish(), + lastName: z.string().nullish(), + username: z.string() + }) + }) + }) + } + }, + handler: async (req) => { + const secretRequest = await req.server.services.secretSharing.getSecretRequestById({ + id: req.params.id, + actorOrgId: req.permission?.orgId, + orgId: req.permission?.orgId, + actor: req.permission?.type, + actorId: req.permission?.id, + actorAuthMethod: req.permission?.authMethod + }); + + return { secretRequest }; + } + }); + + server.route({ + method: "POST", + url: "/:id/set-value", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + secretValue: z.string() + }), + response: { + 200: z.object({ + secretRequest: SecretSharingSchema.omit({ + encryptedSecret: true, + tag: true, + iv: true, + encryptedValue: true + }) + }) + } + }, + handler: async (req) => { + const secretRequest = await req.server.services.secretSharing.setSecretRequestValue({ + id: req.params.id, + actorOrgId: req.permission?.orgId, + orgId: req.permission?.orgId, + actor: req.permission?.type, + actorId: req.permission?.id, + actorAuthMethod: req.permission?.authMethod, + secretValue: req.body.secretValue + }); + + return { secretRequest }; + } + }); + + server.route({ + method: "POST", + url: "/:id/reveal-value", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + secretRequest: SecretSharingSchema.omit({ + encryptedSecret: true, + tag: true, + iv: true, + encryptedValue: true + }).extend({ + secretValue: z.string() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const secretRequest = await req.server.services.secretSharing.revealSecretRequestValue({ + id: req.params.id, + actorOrgId: req.permission.orgId, + orgId: req.permission.orgId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod + }); + + return { secretRequest }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + secretRequest: SecretSharingSchema.omit({ + encryptedSecret: true, + tag: true, + iv: true, + encryptedValue: true + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const secretRequest = await req.server.services.secretSharing.deleteSharedSecretById({ + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + sharedSecretId: req.params.id, + orgId: req.permission.orgId, + actor: req.permission.type, + type: SecretSharingType.Request + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretRequestDeleted, + distinctId: getTelemetryDistinctId(req), + properties: { + secretRequestId: req.params.id, + organizationId: req.permission.orgId, + ...req.auditLogInfo + } + }); + return { secretRequest }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0), + limit: z.coerce.number().min(1).max(100).default(25) + }), + response: { + 200: z.object({ + secrets: z.array(SecretSharingSchema), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { secrets, totalCount } = await req.server.services.secretSharing.getSharedSecrets({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + type: SecretSharingType.Request, + ...req.query + }); + + return { + secrets, + totalCount + }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: z.string().max(50).optional(), + expiresAt: z.string(), + accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization) + }), + response: { + 200: z.object({ + id: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const shareRequest = await req.server.services.secretSharing.createSecretRequest({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_SECRET_REQUEST, + metadata: { + accessType: req.body.accessType, + name: req.body.name, + id: shareRequest.id + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretRequestCreated, + distinctId: getTelemetryDistinctId(req), + properties: { + secretRequestId: shareRequest.id, + organizationId: req.permission.orgId, + secretRequestName: req.body.name, + ...req.auditLogInfo + } + }); + + return { id: shareRequest.id }; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index 59e59ede7..37c8a052f 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -11,6 +11,7 @@ import { } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { SecretSharingType } from "@app/services/secret-sharing/secret-sharing-types"; export const registerSecretSharingRouter = async (server: FastifyZodProvider) => { server.route({ @@ -38,6 +39,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, + type: SecretSharingType.Share, ...req.query }); @@ -211,7 +213,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => orgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - sharedSecretId + sharedSecretId, + type: SecretSharingType.Share }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index aa1ed9d25..f0d579cf7 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -20,7 +20,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { secretDAL: Pick; secretFolderVersionDAL: Pick; snapshotDAL: Pick; - secretSharingDAL: Pick; + secretSharingDAL: Pick; queueService: TQueueServiceFactory; }; @@ -45,6 +45,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await identityAccessTokenDAL.removeExpiredTokens(); await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); await secretSharingDAL.pruneExpiredSharedSecrets(); + await secretSharingDAL.pruneExpiredSecretRequests(); await snapshotDAL.pruneExcessSnapshots(); await secretVersionDAL.pruneExcessVersions(); await secretVersionV2DAL.pruneExcessVersions(); diff --git a/backend/src/services/secret-sharing/secret-sharing-dal.ts b/backend/src/services/secret-sharing/secret-sharing-dal.ts index 5c690b266..a17e37923 100644 --- a/backend/src/services/secret-sharing/secret-sharing-dal.ts +++ b/backend/src/services/secret-sharing/secret-sharing-dal.ts @@ -7,12 +7,58 @@ import { ormify, selectAllTableCols } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; +import { SecretSharingType } from "./secret-sharing-types"; + export type TSecretSharingDALFactory = ReturnType; export const secretSharingDALFactory = (db: TDbClient) => { const sharedSecretOrm = ormify(db, TableName.SecretSharing); - const countAllUserOrgSharedSecrets = async ({ orgId, userId }: { orgId: string; userId: string }) => { + const getSecretRequestById = async (id: string) => { + const repDb = db.replicaNode(); + + const secretRequest = await repDb(TableName.SecretSharing) + .leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.SecretSharing}.orgId`) + .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretSharing}.userId`) + .where(`${TableName.SecretSharing}.id`, id) + .where(`${TableName.SecretSharing}.type`, SecretSharingType.Request) + .select( + repDb.ref("name").withSchema(TableName.Organization).as("orgName"), + repDb.ref("firstName").withSchema(TableName.Users).as("requesterFirstName"), + repDb.ref("lastName").withSchema(TableName.Users).as("requesterLastName"), + repDb.ref("username").withSchema(TableName.Users).as("requesterUsername") + ) + .select(selectAllTableCols(TableName.SecretSharing)) + .first(); + + if (!secretRequest) { + throw new DatabaseError({ + error: new Error("Get Secret Request By Id, Not found"), + message: "Get Secret Request By Id, Not found", + name: "GetSecretRequestById" + }); + } + + return { + ...secretRequest, + requester: { + organizationName: secretRequest.orgName, + firstName: secretRequest.requesterFirstName, + lastName: secretRequest.requesterLastName, + username: secretRequest.requesterUsername + } + }; + }; + + const countAllUserOrgSharedSecrets = async ({ + orgId, + userId, + type + }: { + orgId: string; + userId: string; + type: SecretSharingType; + }) => { try { interface CountResult { count: string; @@ -22,6 +68,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { .replicaNode()(TableName.SecretSharing) .where(`${TableName.SecretSharing}.orgId`, orgId) .where(`${TableName.SecretSharing}.userId`, userId) + .where(`${TableName.SecretSharing}.type`, type) .count("*") .first(); @@ -38,6 +85,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { const docs = await (tx || db)(TableName.SecretSharing) .where("expiresAt", "<", today) .andWhere("encryptedValue", "<>", "") + .andWhere("type", SecretSharingType.Share) .update({ encryptedValue: "", tag: "", @@ -50,6 +98,26 @@ export const secretSharingDALFactory = (db: TDbClient) => { } }; + const pruneExpiredSecretRequests = async (tx?: Knex) => { + logger.info(`${QueueName.DailyResourceCleanUp}: pruning expired secret requests started`); + try { + const today = new Date(); + + const docs = await (tx || db)(TableName.SecretSharing) + .whereNotNull("expiresAt") + .andWhere("expiresAt", "<", today) + .andWhere("encryptedSecret", null) + .andWhere("type", SecretSharingType.Request) + .delete(); + + logger.info(`${QueueName.DailyResourceCleanUp}: pruning expired secret requests completed`); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "pruneExpiredSecretRequests" }); + } + }; + const findActiveSharedSecrets = async (filters: Partial, tx?: Knex) => { try { const now = new Date(); @@ -57,6 +125,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { .where(filters) .andWhere("expiresAt", ">", now) .andWhere("encryptedValue", "<>", "") + .andWhere("type", SecretSharingType.Share) .select(selectAllTableCols(TableName.SecretSharing)) .orderBy("expiresAt", "asc"); } catch (error) { @@ -86,7 +155,9 @@ export const secretSharingDALFactory = (db: TDbClient) => { ...sharedSecretOrm, countAllUserOrgSharedSecrets, pruneExpiredSharedSecrets, + pruneExpiredSecretRequests, softDeleteById, - findActiveSharedSecrets + findActiveSharedSecrets, + getSecretRequestById }; }; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 98ccc988b..45ca9bb2a 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -4,26 +4,36 @@ import bcrypt from "bcrypt"; import { TSecretSharing } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { SecretSharingAccessType } from "@app/lib/types"; import { isUuidV4 } from "@app/lib/validator"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TOrgDALFactory } from "../org/org-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TUserDALFactory } from "../user/user-dal"; import { TSecretSharingDALFactory } from "./secret-sharing-dal"; import { + SecretSharingType, TCreatePublicSharedSecretDTO, + TCreateSecretRequestDTO, TCreateSharedSecretDTO, TDeleteSharedSecretDTO, TGetActiveSharedSecretByIdDTO, - TGetSharedSecretsDTO + TGetSecretRequestByIdDTO, + TGetSharedSecretsDTO, + TRevealSecretRequestValueDTO, + TSetSecretRequestValueDTO } from "./secret-sharing-types"; type TSecretSharingServiceFactoryDep = { permissionService: Pick; secretSharingDAL: TSecretSharingDALFactory; orgDAL: TOrgDALFactory; + userDAL: TUserDALFactory; kmsService: TKmsServiceFactory; + smtpService: TSmtpService; }; export type TSecretSharingServiceFactory = ReturnType; @@ -32,7 +42,9 @@ export const secretSharingServiceFactory = ({ permissionService, secretSharingDAL, orgDAL, - kmsService + kmsService, + smtpService, + userDAL }: TSecretSharingServiceFactoryDep) => { const $validateSharedSecretExpiry = (expiresAt: string) => { if (new Date(expiresAt) < new Date()) { @@ -75,7 +87,6 @@ export const secretSharingServiceFactory = ({ } const encryptWithRoot = kmsService.encryptWithRootKey(); - const encryptedSecret = encryptWithRoot(Buffer.from(secretValue)); const id = crypto.randomBytes(32).toString("hex"); @@ -88,6 +99,7 @@ export const secretSharingServiceFactory = ({ encryptedValue: null, encryptedSecret, name, + type: SecretSharingType.Share, password: hashedPassword, expiresAt: new Date(expiresAt), expiresAfterViews, @@ -101,6 +113,193 @@ export const secretSharingServiceFactory = ({ return { id: idToReturn }; }; + const createSecretRequest = async ({ + actor, + accessType, + expiresAt, + name, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TCreateSecretRequestDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); + + $validateSharedSecretExpiry(expiresAt); + + const newSecretRequest = await secretSharingDAL.create({ + type: SecretSharingType.Request, + userId: actorId, + orgId, + name, + encryptedSecret: null, + accessType, + expiresAt: new Date(expiresAt) + }); + + return { id: newSecretRequest.id }; + }; + + const revealSecretRequestValue = async ({ + id, + actor, + actorId, + actorOrgId, + orgId, + actorAuthMethod + }: TRevealSecretRequestValueDTO) => { + const secretRequest = await secretSharingDAL.getSecretRequestById(id); + + if (!secretRequest) { + throw new NotFoundError({ message: `Secret request with ID '${id}' not found` }); + } + + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); + + if (secretRequest.userId !== actorId || secretRequest.orgId !== orgId) { + throw new ForbiddenRequestError({ name: "User does not have permission to access this secret request" }); + } + + if (!secretRequest.encryptedSecret) { + throw new BadRequestError({ message: "Secret request has no value set" }); + } + + const decryptWithRoot = kmsService.decryptWithRootKey(); + const decryptedSecret = decryptWithRoot(secretRequest.encryptedSecret); + + return { ...secretRequest, secretValue: decryptedSecret.toString() }; + }; + + const getSecretRequestById = async ({ + id, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + }: TGetSecretRequestByIdDTO) => { + const secretRequest = await secretSharingDAL.getSecretRequestById(id); + + if (!secretRequest) { + throw new NotFoundError({ message: `Secret request with ID '${id}' not found` }); + } + + if (secretRequest.accessType === SecretSharingAccessType.Organization) { + if (orgId === undefined) { + throw new UnauthorizedError(); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + ); + if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); + + if (secretRequest.orgId !== orgId) { + throw new ForbiddenRequestError({ name: "User does not have permission to access this secret request" }); + } + } + + if (secretRequest.expiresAt && secretRequest.expiresAt < new Date()) { + throw new ForbiddenRequestError({ + message: "Access denied: Secret request has expired" + }); + } + + return { + ...secretRequest, + isSecretValueSet: Boolean(secretRequest.encryptedSecret) + }; + }; + + const setSecretRequestValue = async ({ + id, + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + secretValue + }: TSetSecretRequestValueDTO) => { + const appCfg = getConfig(); + + const secretRequest = await secretSharingDAL.getSecretRequestById(id); + + if (!secretRequest) { + throw new NotFoundError({ message: `Secret request with ID '${id}' not found` }); + } + + let respondentUsername: string | undefined; + + if (secretRequest.accessType === SecretSharingAccessType.Organization) { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId + ); + if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" }); + + if (!orgId) { + throw new UnauthorizedError(); + } + + if (secretRequest.orgId !== orgId) { + throw new ForbiddenRequestError({ name: "User does not have permission to access this secret request" }); + } + + const user = await userDAL.findById(actorId); + + if (!user) { + throw new NotFoundError({ message: `User with ID '${actorId}' not found` }); + } + + respondentUsername = user.username; + } + + if (secretRequest.encryptedSecret) { + throw new BadRequestError({ message: "Secret request already has a value set" }); + } + + if (secretValue.length > 10_000) { + throw new BadRequestError({ message: "Shared secret value too long" }); + } + + if (secretRequest.expiresAt && secretRequest.expiresAt < new Date()) { + throw new ForbiddenRequestError({ + message: "Access denied: Secret request has expired" + }); + } + + const encryptWithRoot = kmsService.encryptWithRootKey(); + const encryptedSecret = encryptWithRoot(Buffer.from(secretValue)); + + const request = await secretSharingDAL.transaction(async (tx) => { + const updatedRequest = await secretSharingDAL.updateById(id, { encryptedSecret }, tx); + + await smtpService.sendMail({ + recipients: [secretRequest.requesterUsername], + subjectLine: "Secret Request Completed", + substitutions: { + name: secretRequest.name, + respondentUsername, + secretRequestUrl: `${appCfg.SITE_URL}/organization/secret-sharing?selectedTab=request-secret` + }, + template: SmtpTemplates.SecretRequestCompleted + }); + + return updatedRequest; + }); + + return request; + }; + const createPublicSharedSecret = async ({ password, secretValue, @@ -121,6 +320,7 @@ export const secretSharingServiceFactory = ({ encryptedValue: null, iv: null, tag: null, + type: SecretSharingType.Share, encryptedSecret, password: hashedPassword, expiresAt: new Date(expiresAt), @@ -137,7 +337,8 @@ export const secretSharingServiceFactory = ({ actorAuthMethod, actorOrgId, offset, - limit + limit, + type }: TGetSharedSecretsDTO) => { if (!actorOrgId) throw new ForbiddenRequestError(); @@ -153,14 +354,16 @@ export const secretSharingServiceFactory = ({ const secrets = await secretSharingDAL.find( { userId: actorId, - orgId: actorOrgId + orgId: actorOrgId, + type }, { offset, limit, sort: [["createdAt", "desc"]] } ); const count = await secretSharingDAL.countAllUserOrgSharedSecrets({ orgId: actorOrgId, - userId: actorId + userId: actorId, + type }); return { @@ -187,9 +390,11 @@ export const secretSharingServiceFactory = ({ const sharedSecret = isUuidV4(sharedSecretId) ? await secretSharingDAL.findOne({ id: sharedSecretId, + type: SecretSharingType.Share, hashedHex }) : await secretSharingDAL.findOne({ + type: SecretSharingType.Share, identifier: Buffer.from(sharedSecretId, "base64url").toString("hex") }); @@ -254,7 +459,7 @@ export const secretSharingServiceFactory = ({ secret: { ...sharedSecret, ...(decryptedSecretValue && { - secretValue: Buffer.from(decryptedSecretValue).toString() + secretValue: decryptedSecretValue.toString() }), orgName: sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId @@ -270,8 +475,8 @@ export const secretSharingServiceFactory = ({ if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" }); const sharedSecret = isUuidV4(sharedSecretId) - ? await secretSharingDAL.findById(sharedSecretId) - : await secretSharingDAL.findOne({ identifier: sharedSecretId }); + ? await secretSharingDAL.findOne({ id: sharedSecretId, type: deleteSharedSecretInput.type }) + : await secretSharingDAL.findOne({ identifier: sharedSecretId, type: deleteSharedSecretInput.type }); if (sharedSecret.orgId && sharedSecret.orgId !== orgId) throw new ForbiddenRequestError({ message: "User does not have permission to delete shared secret" }); @@ -286,6 +491,11 @@ export const secretSharingServiceFactory = ({ createPublicSharedSecret, getSharedSecrets, deleteSharedSecretById, - getSharedSecretById + getSharedSecretById, + + createSecretRequest, + getSecretRequestById, + setSecretRequestValue, + revealSecretRequestValue }; }; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 1d9efa1e3..0d2dd2393 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -1,8 +1,14 @@ -import { SecretSharingAccessType, TGenericPermission } from "@app/lib/types"; +import { SecretSharingAccessType, TGenericPermission, TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +export enum SecretSharingType { + Share = "share", + Request = "request" +} + export type TGetSharedSecretsDTO = { + type: SecretSharingType; offset: number; limit: number; } & TGenericPermission; @@ -39,6 +45,26 @@ export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & { export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO; +export type TCreateSecretRequestDTO = { + name?: string; + accessType: SecretSharingAccessType; + expiresAt: string; +} & TOrgPermission; + +export type TRevealSecretRequestValueDTO = { + id: string; +} & TOrgPermission; + +export type TGetSecretRequestByIdDTO = { + id: string; +} & TOrgPermission; + +export type TSetSecretRequestValueDTO = { + id: string; + secretValue: string; +} & TOrgPermission; + export type TDeleteSharedSecretDTO = { sharedSecretId: string; + type: SecretSharingType; } & TSharedSecretPermission; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 67168f1dd..68e0ecd22 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -39,7 +39,8 @@ export enum SmtpTemplates { SecretSyncFailed = "secretSyncFailed.handlebars", ExternalImportSuccessful = "externalImportSuccessful.handlebars", ExternalImportFailed = "externalImportFailed.handlebars", - ExternalImportStarted = "externalImportStarted.handlebars" + ExternalImportStarted = "externalImportStarted.handlebars", + SecretRequestCompleted = "secretRequestCompleted.handlebars" } export enum SmtpHost { diff --git a/backend/src/services/smtp/templates/secretRequestCompleted.handlebars b/backend/src/services/smtp/templates/secretRequestCompleted.handlebars new file mode 100644 index 000000000..d2cefdd54 --- /dev/null +++ b/backend/src/services/smtp/templates/secretRequestCompleted.handlebars @@ -0,0 +1,33 @@ + + + + + + Secret Request Completed + + + +

Infisical

+

A secret has been shared with you

+ + {{#if name}} +

Secret request name: {{name}}

+ {{/if}} + {{#if respondentUsername}} +

Shared by: {{respondentUsername}}

+ {{/if}} + +
+
+ +

+ You can access the secret by clicking the link below. +

+

+ Access Secret +

+ + {{emailFooter}} + + + \ No newline at end of file diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index ddeb24211..786afb9d2 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -13,7 +13,9 @@ export enum PostHogEventTypes { IntegrationCreated = "Integration Created", MachineIdentityCreated = "Machine Identity Created", UserOrgInvitation = "User Org Invitation", - TelemetryInstanceStats = "Self Hosted Instance Stats" + TelemetryInstanceStats = "Self Hosted Instance Stats", + SecretRequestCreated = "Secret Request Created", + SecretRequestDeleted = "Secret Request Deleted" } export type TSecretModifiedEvent = { @@ -120,6 +122,23 @@ export type TTelemetryInstanceStatsEvent = { }; }; +export type TSecretRequestCreatedEvent = { + event: PostHogEventTypes.SecretRequestCreated; + properties: { + secretRequestId: string; + organizationId: string; + secretRequestName?: string; + }; +}; + +export type TSecretRequestDeletedEvent = { + event: PostHogEventTypes.SecretRequestDeleted; + properties: { + secretRequestId: string; + organizationId: string; + }; +}; + export type TPostHogEvent = { distinctId: string } & ( | TSecretModifiedEvent | TAdminInitEvent @@ -130,4 +149,6 @@ export type TPostHogEvent = { distinctId: string } & ( | TIntegrationCreatedEvent | TProjectCreateEvent | TTelemetryInstanceStatsEvent + | TSecretRequestCreatedEvent + | TSecretRequestDeletedEvent ); diff --git a/frontend/src/const.ts b/frontend/src/const.ts index 797254893..b2e75f967 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -25,6 +25,7 @@ export const publicPaths = [ "/login/sso", "/admin/signup", "/shared/secret/[id]", + "/secret-request/secret/[id]", "/share-secret" ]; diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 6a41e42b8..c36264f5c 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -21,6 +21,10 @@ export const ROUTE_PATHS = Object.freeze({ "/organization/secret-scanning", "/_authenticate/_inject-org-details/_org-layout/organization/secret-scanning" ), + SecretSharing: setRoute( + "/organization/secret-sharing", + "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing" + ), SettingsPage: setRoute( "/organization/settings", "/_authenticate/_inject-org-details/_org-layout/organization/settings" @@ -285,6 +289,10 @@ export const ROUTE_PATHS = Object.freeze({ ) }, Public: { - ViewSharedSecretByIDPage: setRoute("/shared/secret/$secretId", "/shared/secret/$secretId") + ViewSharedSecretByIDPage: setRoute("/shared/secret/$secretId", "/shared/secret/$secretId"), + ViewSecretRequestByIDPage: setRoute( + "/secret-request/secret/$secretRequestId", + "/secret-request/secret/$secretRequestId" + ) } }); diff --git a/frontend/src/hooks/api/secretSharing/mutations.ts b/frontend/src/hooks/api/secretSharing/mutations.ts index 3f2a3c8ff..8b1485129 100644 --- a/frontend/src/hooks/api/secretSharing/mutations.ts +++ b/frontend/src/hooks/api/secretSharing/mutations.ts @@ -5,8 +5,13 @@ import { apiRequest } from "@app/config/request"; import { secretSharingKeys } from "./queries"; import { TCreatedSharedSecret, + TCreateSecretRequestRequest, TCreateSharedSecretRequest, - TDeleteSharedSecretRequest, + TDeleteSecretRequestDTO, + TDeleteSharedSecretRequestDTO, + TRevealedSecretRequest, + TRevealSecretRequestValueRequest, + TSetSecretRequestValueRequest, TSharedSecret } from "./types"; @@ -15,7 +20,7 @@ export const useCreateSharedSecret = () => { return useMutation({ mutationFn: async (inputData: TCreateSharedSecretRequest) => { const { data } = await apiRequest.post( - "/api/v1/secret-sharing", + "/api/v1/secret-sharing/shared", inputData ); return data; @@ -30,7 +35,7 @@ export const useCreatePublicSharedSecret = () => { return useMutation({ mutationFn: async (inputData: TCreateSharedSecretRequest) => { const { data } = await apiRequest.post( - "/api/v1/secret-sharing/public", + "/api/v1/secret-sharing/shared/public", inputData ); return data; @@ -40,12 +45,50 @@ export const useCreatePublicSharedSecret = () => { }); }; +export const useCreateSecretRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (inputData: TCreateSecretRequestRequest) => { + const { data } = await apiRequest.post( + "/api/v1/secret-sharing/requests", + inputData + ); + return data; + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: secretSharingKeys.allSecretRequests() }) + }); +}; + +export const useSetSecretRequestValue = () => { + return useMutation({ + mutationFn: async (inputData: TSetSecretRequestValueRequest) => { + const { data } = await apiRequest.post( + `/api/v1/secret-sharing/requests/${inputData.id}/set-value`, + inputData + ); + return data; + } + }); +}; + +export const useRevealSecretRequestValue = () => { + return useMutation({ + mutationFn: async (inputData: TRevealSecretRequestValueRequest) => { + const { data } = await apiRequest.post( + `/api/v1/secret-sharing/requests/${inputData.id}/reveal-value`, + inputData + ); + return data.secretRequest; + } + }); +}; export const useDeleteSharedSecret = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequest) => { + mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequestDTO) => { const { data } = await apiRequest.delete( - `/api/v1/secret-sharing/${sharedSecretId}` + `/api/v1/secret-sharing/shared/${sharedSecretId}` ); return data; }, @@ -53,3 +96,19 @@ export const useDeleteSharedSecret = () => { queryClient.invalidateQueries({ queryKey: secretSharingKeys.allSharedSecrets() }) }); }; + +export const useDeleteSecretRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ secretRequestId }: TDeleteSecretRequestDTO) => { + const { data } = await apiRequest.delete( + `/api/v1/secret-sharing/requests/${secretRequestId}` + ); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: secretSharingKeys.allSecretRequests() }); + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts index 84ada6c6f..ace45526a 100644 --- a/frontend/src/hooks/api/secretSharing/queries.ts +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -2,16 +2,20 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TSharedSecret, TViewSharedSecretResponse } from "./types"; +import { TGetSecretRequestByIdResponse, TSharedSecret, TViewSharedSecretResponse } from "./types"; export const secretSharingKeys = { allSharedSecrets: () => ["sharedSecrets"] as const, specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) => [...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const, + allSecretRequests: () => ["secretRequests"] as const, + specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) => + [...secretSharingKeys.allSecretRequests(), { offset, limit }] as const, getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [ "shared-secret", arg - ] + ], + getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const }; export const useGetSharedSecrets = ({ @@ -30,7 +34,7 @@ export const useGetSharedSecrets = ({ }); const { data } = await apiRequest.get<{ secrets: TSharedSecret[]; totalCount: number }>( - "/api/v1/secret-sharing/", + "/api/v1/secret-sharing/shared", { params } @@ -40,6 +44,29 @@ export const useGetSharedSecrets = ({ }); }; +export const useGetSecretRequests = ({ + offset = 0, + limit = 25 +}: { + offset: number; + limit: number; +}) => { + return useQuery({ + queryKey: secretSharingKeys.specificSecretRequests({ offset, limit }), + queryFn: async () => { + const { data } = await apiRequest.get<{ secrets: TSharedSecret[]; totalCount: number }>( + "/api/v1/secret-sharing/requests", + { + params: { + offset: String(offset), + limit: String(limit) + } + } + ); + return data; + } + }); +}; export const useGetActiveSharedSecretById = ({ sharedSecretId, hashedHex, @@ -53,7 +80,7 @@ export const useGetActiveSharedSecretById = ({ queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }), queryFn: async () => { const { data } = await apiRequest.post( - `/api/v1/secret-sharing/public/${sharedSecretId}`, + `/api/v1/secret-sharing/shared/public/${sharedSecretId}`, { ...(hashedHex && { hashedHex }), password @@ -65,3 +92,16 @@ export const useGetActiveSharedSecretById = ({ enabled: Boolean(sharedSecretId) }); }; + +export const useGetSecretRequestById = ({ secretRequestId }: { secretRequestId: string }) => { + return useQuery({ + queryKey: secretSharingKeys.getSecretRequestById({ id: secretRequestId }), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/secret-sharing/requests/${secretRequestId}` + ); + + return data.secretRequest; + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts index b9843a711..6461a1911 100644 --- a/frontend/src/hooks/api/secretSharing/types.ts +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -9,10 +9,17 @@ export type TSharedSecret = { expiresAt: Date; expiresAfterViews: number | null; encryptedValue: string; + encryptedSecret: string; iv: string; tag: string; }; +export type TRevealedSecretRequest = { + secretRequest: { + secretValue: string; + } & TSharedSecret; +}; + export type TCreatedSharedSecret = { id: string; }; @@ -26,6 +33,21 @@ export type TCreateSharedSecretRequest = { accessType?: SecretSharingAccessType; }; +export type TCreateSecretRequestRequest = { + name?: string; + accessType?: SecretSharingAccessType; + expiresAt: Date; +}; + +export type TSetSecretRequestValueRequest = { + secretValue: string; + id: string; +}; + +export type TRevealSecretRequestValueRequest = { + id: string; +}; + export type TViewSharedSecretResponse = { isPasswordProtected: boolean; secret: { @@ -38,10 +60,27 @@ export type TViewSharedSecretResponse = { }; }; -export type TDeleteSharedSecretRequest = { +export type TGetSecretRequestByIdResponse = { + secretRequest: { + isSecretValueSet: boolean; + accessType: SecretSharingAccessType; + requester: { + organizationName: string; + username: string; + firstName?: string; + lastName?: string; + }; + }; +}; + +export type TDeleteSharedSecretRequestDTO = { sharedSecretId: string; }; +export type TDeleteSecretRequestDTO = { + secretRequestId: string; +}; + export enum SecretSharingAccessType { Anyone = "anyone", Organization = "organization" diff --git a/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx b/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx index 2eee282db..a026b86f3 100644 --- a/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx +++ b/frontend/src/pages/organization/SecretSharingPage/SecretSharingPage.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { PageHeader } from "@app/components/v2"; -import { ShareSecretSection } from "./components"; +import { ShareSecretSection } from "./ShareSecretSection"; export const SecretSharingPage = () => { const { t } = useTranslation(); diff --git a/frontend/src/pages/organization/SecretSharingPage/ShareSecretSection.tsx b/frontend/src/pages/organization/SecretSharingPage/ShareSecretSection.tsx new file mode 100644 index 000000000..156f4cbb5 --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingPage/ShareSecretSection.tsx @@ -0,0 +1,75 @@ +import { Helmet } from "react-helmet"; +import { useNavigate, useSearch } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { usePopUp } from "@app/hooks"; +import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing"; + +import { RequestSecretTab } from "./components/RequestSecret/RequestSecretTab"; +import { ShareSecretTab } from "./components/ShareSecret/ShareSecretTab"; + +type DeleteModalData = { name: string; id: string }; + +enum SecretSharingPageTabs { + ShareSecret = "share-secret", + RequestSecret = "request-secret" +} + +export const ShareSecretSection = () => { + const deleteSharedSecret = useDeleteSharedSecret(); + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "createSharedSecret", + "deleteSharedSecretConfirmation", + "createSecretRequest", + "deleteSecretRequestConfirmation", + "revealSecretRequestValue" + ] as const); + + const navigate = useNavigate(); + + const { selectedTab } = useSearch({ + from: ROUTE_PATHS.Organization.SecretSharing.id + }); + + const updateSelectedTab = (tab: string) => { + navigate({ + to: ROUTE_PATHS.Organization.SecretSharing.path, + search: (prev) => ({ ...prev, selectedTab: tab as SecretSharingPageTabs }) + }); + }; + + return ( +
+ + Secret Sharing + + + + + + + Share Secrets + Request Secrets + + + + + + + + +
+ ); +}; diff --git a/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/AddSecretRequestModal.tsx b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/AddSecretRequestModal.tsx new file mode 100644 index 000000000..ec4b95c55 --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/AddSecretRequestModal.tsx @@ -0,0 +1,30 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { RequestSecretForm } from "./RequestSecretForm"; + +type Props = { + popUp: UsePopUpState<["createSecretRequest"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["createSecretRequest"]>, + state?: boolean + ) => void; +}; + +export const AddSecretRequestModal = ({ popUp, handlePopUpToggle }: Props) => { + return ( + { + handlePopUpToggle("createSecretRequest", isOpen); + }} + > + + + + + ); +}; diff --git a/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestSecretForm.tsx b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestSecretForm.tsx new file mode 100644 index 000000000..8b6d1e0b7 --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestSecretForm.tsx @@ -0,0 +1,182 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; +import { SecretSharingAccessType, useCreateSecretRequest } from "@app/hooks/api/secretSharing"; + +const schema = z.object({ + name: z.string().optional(), + accessType: z + .nativeEnum(SecretSharingAccessType) + .default(SecretSharingAccessType.Anyone) + .optional(), + expiresIn: z.string() +}); + +const expiresInOptions = [ + { label: "5 min", value: 5 * 60 * 1000 }, + { label: "30 min", value: 30 * 60 * 1000 }, + { label: "1 hour", value: 60 * 60 * 1000 }, + { label: "1 day", value: 24 * 60 * 60 * 1000 }, + { label: "7 days", value: 7 * 24 * 60 * 60 * 1000 }, + { label: "14 days", value: 14 * 24 * 60 * 60 * 1000 }, + { label: "30 days", value: 30 * 24 * 60 * 60 * 1000 } +]; + +export type FormData = z.infer; + +export const RequestSecretForm = () => { + const [secretLink, setSecretLink] = useState(""); + const [, isCopyingSecret, setCopyTextSecret] = useTimedReset({ + initialState: "Copy to clipboard" + }); + + const { mutateAsync: createSecretRequest } = useCreateSecretRequest(); + + const { + control, + reset, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ name, accessType, expiresIn }: FormData) => { + const expiresAt = new Date(new Date().getTime() + Number(expiresIn)); + + try { + const { id } = await createSecretRequest({ + name, + accessType, + expiresAt + }); + + const link = `${window.location.origin}/secret-request/secret/${id}`; + + setSecretLink(link); + reset(); + + navigator.clipboard.writeText(link); + setCopyTextSecret("secret"); + + createNotification({ + text: "Shared secret link copied to clipboard.", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to create a shared secret.", + type: "error" + }); + } + }; + + const hasSecretLink = Boolean(secretLink); + + return !hasSecretLink ? ( +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + + + ) : ( + <> +
+

{secretLink}

+ { + navigator.clipboard.writeText(secretLink); + setCopyTextSecret("Copied"); + }} + > + + +
+ + + ); +}; diff --git a/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestSecretTab.tsx b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestSecretTab.tsx new file mode 100644 index 000000000..4a17267dd --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestSecretTab.tsx @@ -0,0 +1,97 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { useDeleteSecretRequest } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { AddSecretRequestModal } from "./AddSecretRequestModal"; +import { RequestedSecretsTable } from "./RequestedSecretsTable"; +import { RevealSecretValueModal } from "./RevealSecretValueModal"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["createSecretRequest", "deleteSecretRequestConfirmation", "revealSecretRequestValue"] + >, + data?: any + ) => void; + popUp: UsePopUpState< + ["createSecretRequest", "deleteSecretRequestConfirmation", "revealSecretRequestValue"] + >; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState< + ["createSecretRequest", "deleteSecretRequestConfirmation", "revealSecretRequestValue"] + >, + state?: boolean + ) => void; + handlePopUpClose: ( + popUpName: keyof UsePopUpState<["deleteSecretRequestConfirmation", "revealSecretRequestValue"]> + ) => void; +}; + +type DeleteModalData = { name: string; id: string }; + +export const RequestSecretTab = ({ + handlePopUpOpen, + popUp, + handlePopUpToggle, + handlePopUpClose +}: Props) => { + const { mutateAsync: deleteSecretRequest } = useDeleteSecretRequest(); + + const onDeleteApproved = async () => { + try { + await deleteSecretRequest({ + secretRequestId: popUp.deleteSecretRequestConfirmation.data?.id + }); + createNotification({ + text: "Successfully deleted secret request", + type: "success" + }); + + handlePopUpClose("deleteSecretRequestConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete shared secret", + type: "error" + }); + } + }; + + return ( +
+
+

Secret Requests

+ +
+ + + handlePopUpToggle("revealSecretRequestValue", isOpen)} + /> + handlePopUpToggle("deleteSecretRequestConfirmation", isOpen)} + deleteKey={(popUp?.deleteSecretRequestConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteSecretRequestConfirmation")} + onDeleteApproved={onDeleteApproved} + /> +
+ ); +}; diff --git a/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestedSecretsRow.tsx b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestedSecretsRow.tsx new file mode 100644 index 000000000..939cbae03 --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestedSecretsRow.tsx @@ -0,0 +1,122 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable no-extra-boolean-cast */ +import { faCopy, faEye, faSpinner, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { createNotification } from "@app/components/notifications"; +import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { TSharedSecret, useRevealSecretRequestValue } from "@app/hooks/api/secretSharing"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +export const RequestedSecretsRow = ({ + row, + handlePopUpOpen +}: { + row: TSharedSecret; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSecretRequestConfirmation", "revealSecretRequestValue"]>, + data: unknown + ) => void; +}) => { + const { mutateAsync: revealSecretValue, isPending } = useRevealSecretRequestValue(); + + let isExpired = false; + if (row.expiresAt !== null && new Date(row.expiresAt) < new Date()) { + isExpired = true; + } + + return ( + + {row.name ? `${row.name}` : "-"} + + {isExpired && !row.encryptedSecret ? ( + Expired + ) : ( + + {row.encryptedSecret ? "Secret Provided" : "Pending Secret"} + + )} + + {`${format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")}`} + {row.expiresAt ? format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a") : "-"} + +
+ + { + e.stopPropagation(); + + const secretRequest = await revealSecretValue({ + id: row.id + }); + + console.log("revealSecretRequestValue", { + secretValue: secretRequest.secretValue, + secretRequestName: secretRequest.name + }); + + handlePopUpOpen("revealSecretRequestValue", { + secretValue: secretRequest.secretValue, + secretRequestName: secretRequest.name + }); + }} + variant="plain" + ariaLabel="reveal" + > + + + + + { + e.stopPropagation(); + + navigator.clipboard.writeText( + `${window.location.origin}/secret-request/secret/${row.id}` + ); + + createNotification({ + text: "Shared secret link copied to clipboard.", + type: "success" + }); + }} + variant="plain" + ariaLabel="copy link" + > + + + + + { + e.stopPropagation(); + handlePopUpOpen("deleteSecretRequestConfirmation", { + name: "delete", + id: row.id + }); + }} + variant="plain" + ariaLabel="delete" + > + + + +
+ + + ); +}; diff --git a/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestedSecretsTable.tsx b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestedSecretsTable.tsx new file mode 100644 index 000000000..2392f2123 --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RequestedSecretsTable.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { faKey } from "@fortawesome/free-solid-svg-icons"; + +import { + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useGetSecretRequests } from "@app/hooks/api/secretSharing"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { RequestedSecretsRow } from "./RequestedSecretsRow"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSecretRequestConfirmation", "revealSecretRequestValue"]>, + data: unknown + ) => void; +}; + +export const RequestedSecretsTable = ({ handlePopUpOpen }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const { isPending, data } = useGetSecretRequests({ + offset: (page - 1) * perPage, + limit: perPage + }); + return ( + + + + + + + + + + + + {isPending && } + {!isPending && + data?.secrets?.map((row) => ( + + ))} + +
NameStatusCreated AtValid Until +
+ {!isPending && + data?.secrets && + data?.totalCount >= perPage && + data?.totalCount !== undefined && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.secrets?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RevealSecretValueModal.tsx b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RevealSecretValueModal.tsx new file mode 100644 index 000000000..4c353f5c7 --- /dev/null +++ b/frontend/src/pages/organization/SecretSharingPage/components/RequestSecret/RevealSecretValueModal.tsx @@ -0,0 +1,73 @@ +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, IconButton, Modal, ModalClose, ModalContent, Tooltip } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + isOpen: boolean; + onOpenChange?: (isOpen: boolean) => void; + popUp: UsePopUpState<["revealSecretRequestValue"]>; +}; + +type ContentProps = { + secretValue: string; + secretRequestName?: string; +}; + +const Content = ({ secretValue, secretRequestName }: ContentProps) => { + const [isSecretValueCopied, setIsSecretValueCopied] = useToggle(false); + + return ( + <> + {secretRequestName && ( +

+ Shared secret value for {secretRequestName} +

+ )} + +
+

{secretValue}

+ + { + navigator.clipboard.writeText(secretValue); + setIsSecretValueCopied.on(); + }} + > + + + +
+ +
+ + + +
+ + ); +}; + +export const RevealSecretValueModal = ({ isOpen, onOpenChange, popUp }: Props) => { + const data = popUp.revealSecretRequestValue.data as { + secretValue: string; + secretRequestName?: string; + }; + + const title = data?.secretRequestName + ? `Shared secret value for secret request ${data.secretRequestName}` + : "Shared secret value"; + + return ( + + + + + + ); +}; diff --git a/frontend/src/pages/organization/SecretSharingPage/components/AddShareSecretModal.tsx b/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal.tsx similarity index 100% rename from frontend/src/pages/organization/SecretSharingPage/components/AddShareSecretModal.tsx rename to frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal.tsx diff --git a/frontend/src/pages/organization/SecretSharingPage/components/ShareSecretSection.tsx b/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/ShareSecretTab.tsx similarity index 72% rename from frontend/src/pages/organization/SecretSharingPage/components/ShareSecretSection.tsx rename to frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/ShareSecretTab.tsx index 1de78ca23..115fbfe0b 100644 --- a/frontend/src/pages/organization/SecretSharingPage/components/ShareSecretSection.tsx +++ b/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/ShareSecretTab.tsx @@ -1,27 +1,40 @@ -import { Helmet } from "react-helmet"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { Button, DeleteActionModal } from "@app/components/v2"; -import { usePopUp } from "@app/hooks"; -import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing"; +import { useDeleteSharedSecret } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; import { AddShareSecretModal } from "./AddShareSecretModal"; import { ShareSecretsTable } from "./ShareSecretsTable"; +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["createSharedSecret", "deleteSharedSecretConfirmation"]>, + data?: any + ) => void; + popUp: UsePopUpState<["createSharedSecret", "deleteSharedSecretConfirmation"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["createSharedSecret", "deleteSharedSecretConfirmation"]>, + state?: boolean + ) => void; + handlePopUpClose: (popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>) => void; +}; + type DeleteModalData = { name: string; id: string }; -export const ShareSecretSection = () => { - const deleteSharedSecret = useDeleteSharedSecret(); - const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ - "createSharedSecret", - "deleteSharedSecretConfirmation" - ] as const); +export const ShareSecretTab = ({ + handlePopUpOpen, + popUp, + handlePopUpToggle, + handlePopUpClose +}: Props) => { + const deleteSecretShare = useDeleteSharedSecret(); const onDeleteApproved = async () => { try { - deleteSharedSecret.mutateAsync({ + deleteSecretShare.mutateAsync({ sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id }); createNotification({ @@ -41,11 +54,6 @@ export const ShareSecretSection = () => { return (
- - Secret Sharing - - -

Shared Secrets