diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index f0dcfc8cb..a4bdeb29e 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -35,7 +35,20 @@ jobs: echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env echo "SECRET_SCANNING_PRIVATE_KEY=some-random" >> .env echo "SECRET_SCANNING_WEBHOOK_SECRET=some-random" >> .env - docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET -e ENCRYPTION_KEY=$ENCRYPTION_KEY --env-file .env --entrypoint '/bin/sh' infisical-api + + echo "Examining built image:" + docker image inspect infisical-api | grep -A 5 "Entrypoint" + + docker run --name infisical-api -d -p 4000:4000 \ + -e DB_CONNECTION_URI=$DB_CONNECTION_URI \ + -e REDIS_URL=$REDIS_URL \ + -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET \ + -e ENCRYPTION_KEY=$ENCRYPTION_KEY \ + --env-file .env \ + infisical-api + + echo "Container status right after creation:" + docker ps -a | grep infisical-api env: REDIS_URL: redis://172.17.0.1:6379 DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable @@ -49,21 +62,33 @@ jobs: SECONDS=0 HEALTHY=0 while [ $SECONDS -lt 60 ]; do - if docker ps | grep infisical-api | grep -q healthy; then - echo "Container is healthy." - HEALTHY=1 + # Check if container is running + if docker ps | grep infisical-api; then + # Try to access the API endpoint + if curl -s -f http://localhost:4000/api/docs/json > /dev/null 2>&1; then + echo "API endpoint is responding. Container seems healthy." + HEALTHY=1 + break + fi + else + echo "Container is not running!" + docker ps -a | grep infisical-api break fi + echo "Waiting for container to be healthy... ($SECONDS seconds elapsed)" - - docker logs infisical-api - - sleep 2 - SECONDS=$((SECONDS+2)) + sleep 5 + SECONDS=$((SECONDS+5)) done - + if [ $HEALTHY -ne 1 ]; then echo "Container did not become healthy in time" + echo "Container status:" + docker ps -a | grep infisical-api + echo "Container logs (if any):" + docker logs infisical-api || echo "No logs available" + echo "Container inspection:" + docker inspect infisical-api | grep -A 5 "State" exit 1 fi - name: Install openapi-diff @@ -71,7 +96,8 @@ jobs: - name: Running OpenAPI Spec diff action run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR - name: cleanup + if: always() run: | docker compose -f "docker-compose.dev.yml" down - docker stop infisical-api - docker remove infisical-api + docker stop infisical-api || true + docker rm infisical-api || true \ No newline at end of file diff --git a/backend/src/db/migrations/20250305080145_add-secret-review-comment.ts b/backend/src/db/migrations/20250305080145_add-secret-review-comment.ts new file mode 100644 index 000000000..7d51bb226 --- /dev/null +++ b/backend/src/db/migrations/20250305080145_add-secret-review-comment.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "comment"))) { + await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (t) => { + t.string("comment"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "comment")) { + await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (t) => { + t.dropColumn("comment"); + }); + } +} diff --git a/backend/src/db/migrations/20250305131152_add-actor-id-to-secret-versions-v2.ts b/backend/src/db/migrations/20250305131152_add-actor-id-to-secret-versions-v2.ts new file mode 100644 index 000000000..fb9a047af --- /dev/null +++ b/backend/src/db/migrations/20250305131152_add-actor-id-to-secret-versions-v2.ts @@ -0,0 +1,45 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretVersionV2)) { + const hasSecretVersionV2UserActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "userActorId"); + const hasSecretVersionV2IdentityActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "identityActorId"); + const hasSecretVersionV2ActorType = await knex.schema.hasColumn(TableName.SecretVersionV2, "actorType"); + + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + if (!hasSecretVersionV2UserActorId) { + t.uuid("userActorId"); + t.foreign("userActorId").references("id").inTable(TableName.Users); + } + if (!hasSecretVersionV2IdentityActorId) { + t.uuid("identityActorId"); + t.foreign("identityActorId").references("id").inTable(TableName.Identity); + } + if (!hasSecretVersionV2ActorType) { + t.string("actorType"); + } + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretVersionV2)) { + const hasSecretVersionV2UserActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "userActorId"); + const hasSecretVersionV2IdentityActorId = await knex.schema.hasColumn(TableName.SecretVersionV2, "identityActorId"); + const hasSecretVersionV2ActorType = await knex.schema.hasColumn(TableName.SecretVersionV2, "actorType"); + + await knex.schema.alterTable(TableName.SecretVersionV2, (t) => { + if (hasSecretVersionV2UserActorId) { + t.dropColumn("userActorId"); + } + if (hasSecretVersionV2IdentityActorId) { + t.dropColumn("identityActorId"); + } + if (hasSecretVersionV2ActorType) { + t.dropColumn("actorType"); + } + }); + } +} diff --git a/backend/src/db/schemas/secret-approval-requests-reviewers.ts b/backend/src/db/schemas/secret-approval-requests-reviewers.ts index a5c445587..147646b8d 100644 --- a/backend/src/db/schemas/secret-approval-requests-reviewers.ts +++ b/backend/src/db/schemas/secret-approval-requests-reviewers.ts @@ -13,7 +13,8 @@ export const SecretApprovalRequestsReviewersSchema = z.object({ requestId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - reviewerUserId: z.string().uuid() + reviewerUserId: z.string().uuid(), + comment: z.string().nullable().optional() }); export type TSecretApprovalRequestsReviewers = z.infer; diff --git a/backend/src/db/schemas/secret-versions-v2.ts b/backend/src/db/schemas/secret-versions-v2.ts index 160ed1c14..593a46b06 100644 --- a/backend/src/db/schemas/secret-versions-v2.ts +++ b/backend/src/db/schemas/secret-versions-v2.ts @@ -25,7 +25,10 @@ export const SecretVersionsV2Schema = z.object({ folderId: z.string().uuid(), userId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + userActorId: z.string().uuid().nullable().optional(), + identityActorId: z.string().uuid().nullable().optional(), + actorType: z.string().nullable().optional() }); export type TSecretVersionsV2 = z.infer; diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index c6998f105..653d04d4f 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -159,7 +159,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv id: z.string() }), body: z.object({ - status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]), + comment: z.string().optional() }), response: { 200: z.object({ @@ -175,8 +176,25 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, approvalId: req.params.id, - status: req.body.status + status: req.body.status, + comment: req.body.comment }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: review.projectId, + event: { + type: EventType.SECRET_APPROVAL_REQUEST_REVIEW, + metadata: { + secretApprovalRequestId: review.requestId, + reviewedBy: review.reviewerUserId, + status: review.status as ApprovalStatus, + comment: review.comment || "" + } + } + }); + return { review }; } }); @@ -267,7 +285,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), committerUser: approvalRequestUser, - reviewers: approvalRequestUser.extend({ status: z.string() }).array(), + reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), secretPath: z.string(), commits: secretRawSchema .omit({ _id: true, environment: true, workspace: true, type: true, version: true }) 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 85e48872e..a8f96bb73 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -22,6 +22,7 @@ import { } from "@app/services/secret-sync/secret-sync-types"; import { KmipPermission } from "../kmip/kmip-enum"; +import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; export type TListProjectAuditLogDTO = { filter: { @@ -165,6 +166,7 @@ export enum EventType { SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", SECRET_APPROVAL_REOPENED = "secret-approval-reopened", + SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review", SIGN_SSH_KEY = "sign-ssh-key", ISSUE_SSH_CREDS = "issue-ssh-creds", CREATE_SSH_CA = "create-ssh-certificate-authority", @@ -1314,6 +1316,16 @@ interface SecretApprovalRequest { }; } +interface SecretApprovalRequestReview { + type: EventType.SECRET_APPROVAL_REQUEST_REVIEW; + metadata: { + secretApprovalRequestId: string; + reviewedBy: string; + status: ApprovalStatus; + comment: string; + }; +} + interface SignSshKey { type: EventType.SIGN_SSH_KEY; metadata: { @@ -2482,4 +2494,5 @@ export type Event = | KmipOperationRevokeEvent | KmipOperationLocateEvent | KmipOperationRegisterEvent - | CreateSecretRequestEvent; + | CreateSecretRequestEvent + | SecretApprovalRequestReview; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index f842359bc..5fc869d12 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -100,6 +100,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("lastName").withSchema("committerUser").as("committerUserLastName"), tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer), tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"), + tx.ref("comment").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerComment"), tx.ref("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"), tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), @@ -162,8 +163,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { reviewerEmail: email, reviewerLastName: lastName, reviewerUsername: username, - reviewerFirstName: firstName - }) => (userId ? { userId, status, email, firstName, lastName, username } : undefined) + reviewerFirstName: firstName, + reviewerComment: comment + }) => + userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined }, { key: "approverUserId", diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 17eecf508..d296d7975 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -320,6 +320,7 @@ export const secretApprovalRequestServiceFactory = ({ approvalId, actor, status, + comment, actorId, actorAuthMethod, actorOrgId @@ -372,15 +373,18 @@ export const secretApprovalRequestServiceFactory = ({ return secretApprovalRequestReviewerDAL.create( { status, + comment, requestId: secretApprovalRequest.id, reviewerUserId: actorId }, tx ); } - return secretApprovalRequestReviewerDAL.updateById(review.id, { status }, tx); + + return secretApprovalRequestReviewerDAL.updateById(review.id, { status, comment }, tx); }); - return reviewStatus; + + return { ...reviewStatus, projectId: secretApprovalRequest.projectId }; }; const updateApprovalStatus = async ({ @@ -499,7 +503,7 @@ export const secretApprovalRequestServiceFactory = ({ if (!hasMinApproval && !isSoftEnforcement) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); - const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId); let mergeStatus; if (shouldUseSecretV2Bridge) { // this cycle if for bridged secrets @@ -857,7 +861,6 @@ export const secretApprovalRequestServiceFactory = ({ if (isSoftEnforcement) { const cfg = getConfig(); - const project = await projectDAL.findProjectById(projectId); const env = await projectEnvDAL.findOne({ id: policy.envId }); const requestedByUser = await userDAL.findOne({ id: actorId }); const approverUsers = await userDAL.find({ @@ -1152,7 +1155,8 @@ export const secretApprovalRequestServiceFactory = ({ environment: env.name, secretPath, projectId, - requestId: secretApprovalRequest.id + requestId: secretApprovalRequest.id, + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))] } } }); @@ -1452,7 +1456,8 @@ export const secretApprovalRequestServiceFactory = ({ environment: env.name, secretPath, projectId, - requestId: secretApprovalRequest.id + requestId: secretApprovalRequest.id, + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))] } } }); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 89af253dd..5d6358072 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -80,6 +80,7 @@ export type TStatusChangeDTO = { export type TReviewRequestDTO = { approvalId: string; status: ApprovalStatus; + comment?: string; } & Omit; export type TApprovalRequestCountDTO = TProjectPermission; diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index fdc493b9f..ac8fcc9f2 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts @@ -13,6 +13,7 @@ import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -332,6 +333,7 @@ export const secretRotationQueueFactory = ({ await secretVersionV2BridgeDAL.insertMany( updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({ ...el, + actorType: ActorType.PLATFORM, secretId: id })), tx diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 1c34f6b3d..dc2cff456 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -7,6 +7,7 @@ import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { InternalServerError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -370,7 +371,21 @@ export const secretSnapshotServiceFactory = ({ const secrets = await secretV2BridgeDAL.insertMany( rollbackSnaps.flatMap(({ secretVersions, folderId }) => secretVersions.map( - ({ latestSecretVersion, version, updatedAt, createdAt, secretId, envId, id, tags, ...el }) => ({ + ({ + latestSecretVersion, + version, + updatedAt, + createdAt, + secretId, + envId, + id, + tags, + // exclude the bottom fields from the secret - they are for versioning only. + userActorId, + identityActorId, + actorType, + ...el + }) => ({ ...el, id: secretId, version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, @@ -401,8 +416,18 @@ export const secretSnapshotServiceFactory = ({ })), tx ); + const userActorId = actor === ActorType.USER ? actorId : undefined; + const identityActorId = actor !== ActorType.USER ? actorId : undefined; + const actorType = actor || ActorType.PLATFORM; + const secretVersions = await secretVersionV2BridgeDAL.insertMany( - secrets.map(({ id, updatedAt, createdAt, ...el }) => ({ ...el, secretId: id })), + secrets.map(({ id, updatedAt, createdAt, ...el }) => ({ + ...el, + secretId: id, + userActorId, + identityActorId, + actorType + })), tx ); await secretVersionV2TagBridgeDAL.insertMany( diff --git a/backend/src/lib/gateway/index.ts b/backend/src/lib/gateway/index.ts index 09e92b0c2..8d25c2af6 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import net from "node:net"; -import * as quic from "@infisical/quic"; +import quicDefault, * as quicModule from "@infisical/quic"; import { BadRequestError } from "../errors"; import { logger } from "../logger"; @@ -10,6 +10,8 @@ import { logger } from "../logger"; const DEFAULT_MAX_RETRIES = 3; const DEFAULT_RETRY_DELAY = 1000; // 1 second +const quic = quicDefault || quicModule; + const parseSubjectDetails = (data: string) => { const values: Record = {}; data.split("\n").forEach((el) => { diff --git a/backend/src/main.ts b/backend/src/main.ts index 461601fc0..d5c54991b 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -83,6 +83,14 @@ const run = async () => { process.exit(0); }); + process.on("uncaughtException", (error) => { + logger.error(error, "CRITICAL ERROR: Uncaught Exception"); + }); + + process.on("unhandledRejection", (error) => { + logger.error(error, "CRITICAL ERROR: Unhandled Promise Rejection"); + }); + await server.listen({ port: envConfig.PORT, host: envConfig.HOST, diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index f9aec5881..5d6b8b60b 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -21,6 +21,7 @@ import { TQueueSecretSyncSyncSecretsByIdDTO, TQueueSendSecretSyncActionFailedNotificationsDTO } from "@app/services/secret-sync/secret-sync-types"; +import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; export enum QueueName { SecretRotation = "secret-rotation", @@ -107,7 +108,7 @@ export type TQueueJobTypes = { }; [QueueName.SecretWebhook]: { name: QueueJobs.SecWebhook; - payload: { projectId: string; environment: string; secretPath: string; depth?: number }; + payload: TWebhookPayloads; }; [QueueName.AccessTokenStatusUpdate]: diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 4d645ac4b..a6cc8bae0 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -111,7 +111,16 @@ export const secretRawSchema = z.object({ secretReminderRepeatDays: z.number().nullable().optional(), skipMultilineEncoding: z.boolean().default(false).nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + actor: z + .object({ + actorId: z.string().nullable().optional(), + actorType: z.string().nullable().optional(), + name: z.string().nullable().optional(), + membershipId: z.string().nullable().optional() + }) + .optional() + .nullable() }); export const ProjectPermissionSchema = z.object({ diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index a5dc39485..4935345dc 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -380,6 +380,48 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/raw/id/:secretId", + config: { + rateLimit: secretsLimit + }, + schema: { + params: z.object({ + secretId: z.string() + }), + response: { + 200: z.object({ + secret: secretRawSchema.extend({ + secretPath: z.string(), + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + color: true + }) + .extend({ name: z.string() }) + .array() + .optional(), + secretMetadata: ResourceMetadataSchema.optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { secretId } = req.params; + const secret = await server.services.secret.getSecretByIdRaw({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretId + }); + + return { secret }; + } + }); + server.route({ method: "GET", url: "/raw/:secretName", diff --git a/backend/src/services/external-migration/external-migration-fns.ts b/backend/src/services/external-migration/external-migration-fns.ts index 744678792..f4a54f0db 100644 --- a/backend/src/services/external-migration/external-migration-fns.ts +++ b/backend/src/services/external-migration/external-migration-fns.ts @@ -772,6 +772,10 @@ export const importDataIntoInfisicalFn = async ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }); } diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 1d9fedde7..eb17c05bb 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -114,20 +114,27 @@ export const integrationAuthServiceFactory = ({ const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => { const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string); - return Promise.all( - authorizations.filter(async (auth) => { - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: auth.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.SecretManager - }); + const filteredAuthorizations = await Promise.all( + authorizations.map(async (auth) => { + try { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: auth.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); - return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations) ? auth : null; + } catch (error) { + // user does not belong to the project that the integration auth belongs to + return null; + } }) ); + + return filteredAuthorizations.filter((auth): auth is NonNullable => auth !== null); }; const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => { diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 99980fba7..b4619abd3 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -613,6 +613,9 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + + .leftJoin(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`) + .leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .select(selectAllTableCols(TableName.SecretV2)) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) @@ -622,12 +625,13 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") - ); + ) + .select(db.ref("projectId").withSchema(TableName.Environment).as("projectId")); const docs = sqlNestRelationships({ data: rawDocs, key: "id", - parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + parentMapper: (el) => ({ _id: el.id, projectId: el.projectId, ...SecretsV2Schema.parse(el) }), childrenMapper: [ { key: "tagId", diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index cc40b0f26..751235cde 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -5,6 +5,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { ActorType } from "../auth/auth-type"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -62,6 +63,7 @@ export const fnSecretBulkInsert = async ({ resourceMetadataDAL, secretTagDAL, secretVersionTagDAL, + actor, tx }: TFnSecretBulkInsert) => { const sanitizedInputSecrets = inputSecrets.map( @@ -90,6 +92,10 @@ export const fnSecretBulkInsert = async ({ }) ); + const userActorId = actor && actor.type === ActorType.USER ? actor.actorId : undefined; + const identityActorId = actor && actor.type !== ActorType.USER ? actor.actorId : undefined; + const actorType = actor?.type || ActorType.PLATFORM; + const newSecrets = await secretDAL.insertMany( sanitizedInputSecrets.map((el) => ({ ...el, folderId })), tx @@ -106,6 +112,9 @@ export const fnSecretBulkInsert = async ({ sanitizedInputSecrets.map((el) => ({ ...el, folderId, + userActorId, + identityActorId, + actorType, secretId: newSecretGroupedByKeyName[el.key][0].id })), tx @@ -157,8 +166,13 @@ export const fnSecretBulkUpdate = async ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, - resourceMetadataDAL + resourceMetadataDAL, + actor }: TFnSecretBulkUpdate) => { + const userActorId = actor && actor?.type === ActorType.USER ? actor?.actorId : undefined; + const identityActorId = actor && actor?.type !== ActorType.USER ? actor?.actorId : undefined; + const actorType = actor?.type || ActorType.PLATFORM; + const sanitizedInputSecrets = inputSecrets.map( ({ filter, @@ -216,7 +230,10 @@ export const fnSecretBulkUpdate = async ({ encryptedValue, reminderRepeatDays, folderId, - secretId + secretId, + userActorId, + identityActorId, + actorType }) ), tx @@ -616,6 +633,12 @@ export const reshapeBridgeSecret = ( secret: Omit & { value: string; comment: string; + userActorName?: string | null; + identityActorName?: string | null; + userActorId?: string | null; + identityActorId?: string | null; + membershipId?: string | null; + actorType?: string | null; tags?: { id: string; slug: string; @@ -636,6 +659,14 @@ export const reshapeBridgeSecret = ( _id: secret.id, id: secret.id, user: secret.userId, + actor: secret.actorType + ? { + actorType: secret.actorType, + actorId: secret.userActorId || secret.identityActorId, + name: secret.identityActorName || secret.userActorName, + membershipId: secret.membershipId + } + : undefined, tags: secret.tags, skipMultilineEncoding: secret.skipMultilineEncoding, secretReminderRepeatDays: secret.reminderRepeatDays, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 0ffb0ea4c..d6ba02856 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -28,6 +28,7 @@ import { KmsDataKey } from "../kms/kms-types"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { TSecretQueueFactory } from "../secret/secret-queue"; +import { TGetASecretByIdDTO } from "../secret/secret-types"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; @@ -73,7 +74,13 @@ type TSecretV2BridgeServiceFactoryDep = { projectEnvDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, - "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv" + | "findBySecretPath" + | "updateById" + | "findById" + | "findByManySecretPath" + | "find" + | "findBySecretPathMultiEnv" + | "findSecretPathByFolderIds" >; secretImportDAL: Pick; secretQueueService: Pick; @@ -301,6 +308,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -483,6 +494,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -947,6 +962,73 @@ export const secretV2BridgeServiceFactory = ({ }; }; + const getSecretById = async ({ actorId, actor, actorOrgId, actorAuthMethod, secretId }: TGetASecretByIdDTO) => { + const secret = await secretDAL.findOneWithTags({ + [`${TableName.SecretV2}.id` as "id"]: secretId + }); + + if (!secret) { + throw new NotFoundError({ + message: `Secret with ID '${secretId}' not found`, + name: "GetSecretById" + }); + } + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secret.projectId, [secret.folderId]); + + if (!folderWithPath) { + throw new NotFoundError({ + message: `Folder with id '${secret.folderId}' not found`, + name: "GetSecretById" + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: secret.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { + environment: folderWithPath.environmentSlug, + secretPath: folderWithPath.path, + secretName: secret.key, + secretTags: secret.tags.map((i) => i.slug) + }) + ); + + if (secret.type === SecretType.Personal && secret.userId !== actorId) { + throw new ForbiddenRequestError({ + message: "You are not allowed to access this secret", + name: "GetSecretById" + }); + } + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: secret.projectId + }); + + const secretValue = secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : ""; + + const secretComment = secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : ""; + + return reshapeBridgeSecret(secret.projectId, folderWithPath.environmentSlug, folderWithPath.path, { + ...secret, + value: secretValue, + comment: secretComment + }); + }; + const getSecretByName = async ({ actorId, actor, @@ -1230,6 +1312,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }) ); @@ -1490,6 +1576,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, resourceMetadataDAL }); updatedSecrets.push(...bulkUpdatedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); @@ -1522,6 +1612,10 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, tx }); updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); @@ -1689,14 +1783,19 @@ export const secretV2BridgeServiceFactory = ({ type: KmsDataKey.SecretManager, projectId: folder.projectId }); - const secretVersions = await secretVersionDAL.find({ secretId }, { offset, limit, sort: [["createdAt", "desc"]] }); - return secretVersions.map((el) => - reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, "/", { + const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors(secretId, folder.projectId, { + offset, + limit, + sort: [["createdAt", "desc"]] + }); + + return secretVersions.map((el) => { + return reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, "/", { ...el, value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }) - ); + }); + }); }; // this is a backfilling API for secret references @@ -1956,6 +2055,10 @@ export const secretV2BridgeServiceFactory = ({ secretTagDAL, resourceMetadataDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, inputSecrets: locallyCreatedSecrets.map((doc) => { return { type: doc.type, @@ -1982,6 +2085,10 @@ export const secretV2BridgeServiceFactory = ({ tx, secretTagDAL, secretVersionTagDAL, + actor: { + type: actor, + actorId + }, inputSecrets: locallyUpdatedSecrets.map((doc) => { return { filter: { @@ -2204,6 +2311,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretsCountMultiEnv, getSecretsMultiEnv, getSecretReferenceTree, - getSecretsByFolderMappings + getSecretsByFolderMappings, + getSecretById }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index ad8264e81..22956463d 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -168,6 +168,10 @@ export type TFnSecretBulkInsert = { secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; + actor?: { + type: string; + actorId: string; + }; }; type TRequireReferenceIfValue = @@ -192,6 +196,10 @@ export type TFnSecretBulkUpdate = { secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; + actor?: { + type: string; + actorId: string; + }; tx?: Knex; }; diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index 7772b8518..d06aa1472 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -1,9 +1,10 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; @@ -119,11 +120,67 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v2 completed`); }; + const findVersionsBySecretIdWithActors = async ( + secretId: string, + projectId: string, + { offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt = {}, + tx?: Knex + ) => { + try { + const query = (tx || db)(TableName.SecretVersionV2) + .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`) + .leftJoin( + TableName.ProjectMembership, + `${TableName.ProjectMembership}.userId`, + `${TableName.SecretVersionV2}.userActorId` + ) + .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) + .where((qb) => { + void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); + void qb.where(`${TableName.ProjectMembership}.projectId`, projectId); + }) + .orWhere((qb) => { + void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); + void qb.whereNull(`${TableName.ProjectMembership}.projectId`); + }) + .select( + selectAllTableCols(TableName.SecretVersionV2), + `${TableName.Users}.username as userActorName`, + `${TableName.Identity}.name as identityActorName`, + `${TableName.ProjectMembership}.id as membershipId` + ); + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy( + sort.map(([column, order, nulls]) => ({ + column: `${TableName.SecretVersionV2}.${column as string}`, + order, + nulls + })) + ); + } + + const docs: Array< + TSecretVersionsV2 & { + userActorName: string | undefined | null; + identityActorName: string | undefined | null; + membershipId: string | undefined | null; + } + > = await query; + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindVersionsBySecretIdWithActors" }); + } + }; + return { ...secretVersionV2Orm, pruneExcessVersions, findLatestVersionMany, bulkUpdate, - findLatestVersionByFolderId + findLatestVersionByFolderId, + findVersionsBySecretIdWithActors }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 1775d1f44..d7c9b86fb 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -579,6 +579,7 @@ export const fnSecretBulkInsert = async ({ [`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id })) ); + const secretVersions = await secretVersionDAL.insertMany( sanitizedInputSecrets.map((el) => ({ ...el, diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 00b0e7da8..c84fe5ae0 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -61,6 +61,7 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { fnTriggerWebhook } from "../webhook/webhook-fns"; +import { WebhookEvents } from "../webhook/webhook-types"; import { TSecretDALFactory } from "./secret-dal"; import { interpolateSecrets } from "./secret-fns"; import { @@ -623,7 +624,14 @@ export const secretQueueFactory = ({ await queueService.queue( QueueName.SecretWebhook, QueueJobs.SecWebhook, - { environment, projectId, secretPath }, + { + type: WebhookEvents.SecretModified, + payload: { + environment, + projectId, + secretPath + } + }, { jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`, removeOnFail: { count: 5 }, @@ -1055,6 +1063,8 @@ export const secretQueueFactory = ({ const organization = await orgDAL.findOrgByProjectId(projectId); const project = await projectDAL.findById(projectId); + const secret = await secretV2BridgeDAL.findById(data.secretId); + const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]); if (!organization) { logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); @@ -1083,6 +1093,19 @@ export const secretQueueFactory = ({ organizationName: organization.name } }); + + await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, { + type: WebhookEvents.SecretReminderExpired, + payload: { + projectName: project.name, + projectId: project.id, + secretPath: folder?.path, + environment: folder?.environmentSlug || "", + reminderNote: data.note, + secretName: secret?.key, + secretId: data.secretId + } + }); }); const startSecretV2Migration = async (projectId: string) => { @@ -1490,14 +1513,17 @@ export const secretQueueFactory = ({ queueService.start(QueueName.SecretWebhook, async (job) => { const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, - projectId: job.data.projectId + projectId: job.data.payload.projectId }); await fnTriggerWebhook({ - ...job.data, + projectId: job.data.payload.projectId, + environment: job.data.payload.environment, + secretPath: job.data.payload.secretPath || "/", projectEnvDAL, - webhookDAL, projectDAL, + webhookDAL, + event: job.data, secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString() }); }); diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 93f68e813..cfb47d1dd 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -71,6 +71,7 @@ import { TDeleteManySecretRawDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, + TGetASecretByIdRawDTO, TGetASecretDTO, TGetASecretRawDTO, TGetSecretAccessListDTO, @@ -95,7 +96,7 @@ type TSecretServiceFactoryDep = { projectEnvDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, - "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" + "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findSecretPathByFolderIds" >; secretV2BridgeService: TSecretV2BridgeServiceFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; @@ -1382,6 +1383,18 @@ export const secretServiceFactory = ({ }; }; + const getSecretByIdRaw = async ({ secretId, actorId, actor, actorOrgId, actorAuthMethod }: TGetASecretByIdRawDTO) => { + const secret = await secretV2BridgeService.getSecretById({ + secretId, + actorId, + actor, + actorOrgId, + actorAuthMethod + }); + + return secret; + }; + const getSecretByNameRaw = async ({ type, path, @@ -3088,6 +3101,7 @@ export const secretServiceFactory = ({ getSecretsRawMultiEnv, getSecretReferenceTree, getSecretsRawByFolderMappings, - getSecretAccessList + getSecretAccessList, + getSecretByIdRaw }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 158605276..46aedc2ee 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -121,6 +121,10 @@ export type TGetASecretDTO = { version?: number; } & TProjectPermission; +export type TGetASecretByIdDTO = { + secretId: string; +} & Omit; + export type TCreateBulkSecretDTO = { path: string; environment: string; @@ -213,6 +217,10 @@ export type TGetASecretRawDTO = { projectId?: string; } & Omit; +export type TGetASecretByIdRawDTO = { + secretId: string; +} & Omit; + export type TCreateSecretRawDTO = TProjectPermission & { secretName: string; secretPath: string; diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 14ab6a94c..f92f96a24 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -50,6 +50,7 @@ const buildSlackPayload = (notification: TSlackNotification) => { const messageBody = `A secret approval request has been opened by ${payload.userEmail}. *Environment*: ${payload.environment} *Secret path*: ${payload.secretPath || "/"} +*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")} View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId}/approval?requestId=${ payload.requestId diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index a1914eee2..a92ba4e8b 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -62,6 +62,7 @@ export type TSlackNotification = secretPath: string; requestId: string; projectId: string; + secretKeys: string[]; }; } | { diff --git a/backend/src/services/webhook/webhook-fns.ts b/backend/src/services/webhook/webhook-fns.ts index e46f9db2a..a16158e14 100644 --- a/backend/src/services/webhook/webhook-fns.ts +++ b/backend/src/services/webhook/webhook-fns.ts @@ -11,7 +11,7 @@ import { logger } from "@app/lib/logger"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TWebhookDALFactory } from "./webhook-dal"; -import { WebhookType } from "./webhook-types"; +import { TWebhookPayloads, WebhookEvents, WebhookType } from "./webhook-types"; const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000; @@ -54,29 +54,64 @@ export const triggerWebhookRequest = async ( return req; }; -export const getWebhookPayload = ( - eventName: string, - details: { - workspaceName: string; - workspaceId: string; - environment: string; - secretPath?: string; - type?: string | null; +export const getWebhookPayload = (event: TWebhookPayloads) => { + if (event.type === WebhookEvents.SecretModified) { + const { projectName, projectId, environment, secretPath, type } = event.payload; + + switch (type) { + case WebhookType.SLACK: + return { + text: "A secret value has been added or modified.", + attachments: [ + { + color: "#E7F256", + fields: [ + { + title: "Project", + value: projectName, + short: false + }, + { + title: "Environment", + value: environment, + short: false + }, + { + title: "Secret Path", + value: secretPath, + short: false + } + ] + } + ] + }; + case WebhookType.GENERAL: + default: + return { + event: event.type, + project: { + workspaceId: projectId, + projectName, + environment, + secretPath + } + }; + } } -) => { - const { workspaceName, workspaceId, environment, secretPath, type } = details; + + const { projectName, projectId, environment, secretPath, type, reminderNote, secretName } = event.payload; switch (type) { case WebhookType.SLACK: return { - text: "A secret value has been added or modified.", + text: "You have a secret reminder", attachments: [ { color: "#E7F256", fields: [ { title: "Project", - value: workspaceName, + value: projectName, short: false }, { @@ -88,6 +123,16 @@ export const getWebhookPayload = ( title: "Secret Path", value: secretPath, short: false + }, + { + title: "Secret Name", + value: secretName, + short: false + }, + { + title: "Reminder Note", + value: reminderNote, + short: false } ] } @@ -96,11 +141,14 @@ export const getWebhookPayload = ( case WebhookType.GENERAL: default: return { - event: eventName, + event: event.type, project: { - workspaceId, + workspaceId: projectId, + projectName, environment, - secretPath + secretPath, + secretName, + reminderNote } }; } @@ -110,6 +158,7 @@ export type TFnTriggerWebhookDTO = { projectId: string; secretPath: string; environment: string; + event: TWebhookPayloads; webhookDAL: Pick; projectEnvDAL: Pick; projectDAL: Pick; @@ -124,8 +173,9 @@ export const fnTriggerWebhook = async ({ projectId, webhookDAL, projectEnvDAL, - projectDAL, - secretManagerDecryptor + event, + secretManagerDecryptor, + projectDAL }: TFnTriggerWebhookDTO) => { const webhooks = await webhookDAL.findAllWebhooks(projectId, environment); const toBeTriggeredHooks = webhooks.filter( @@ -134,21 +184,20 @@ export const fnTriggerWebhook = async ({ ); if (!toBeTriggeredHooks.length) return; logger.info({ environment, secretPath, projectId }, "Secret webhook job started"); - const project = await projectDAL.findById(projectId); + let { projectName } = event.payload; + if (!projectName) { + const project = await projectDAL.findById(event.payload.projectId); + projectName = project.name; + } + const webhooksTriggered = await Promise.allSettled( - toBeTriggeredHooks.map((hook) => - triggerWebhookRequest( - hook, - secretManagerDecryptor, - getWebhookPayload("secrets.modified", { - workspaceName: project.name, - workspaceId: projectId, - environment, - secretPath, - type: hook.type - }) - ) - ) + toBeTriggeredHooks.map((hook) => { + const formattedEvent = { + type: event.type, + payload: { ...event.payload, type: hook.type, projectName } + } as TWebhookPayloads; + return triggerWebhookRequest(hook, secretManagerDecryptor, getWebhookPayload(formattedEvent)); + }) ); // filter hooks by status diff --git a/backend/src/services/webhook/webhook-service.ts b/backend/src/services/webhook/webhook-service.ts index bb078e0f1..c555dc8d1 100644 --- a/backend/src/services/webhook/webhook-service.ts +++ b/backend/src/services/webhook/webhook-service.ts @@ -16,7 +16,8 @@ import { TDeleteWebhookDTO, TListWebhookDTO, TTestWebhookDTO, - TUpdateWebhookDTO + TUpdateWebhookDTO, + WebhookEvents } from "./webhook-types"; type TWebhookServiceFactoryDep = { @@ -144,12 +145,15 @@ export const webhookServiceFactory = ({ await triggerWebhookRequest( webhook, (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString(), - getWebhookPayload("test", { - workspaceName: project.name, - workspaceId: webhook.projectId, - environment: webhook.environment.slug, - secretPath: webhook.secretPath, - type: webhook.type + getWebhookPayload({ + type: "test" as WebhookEvents.SecretModified, + payload: { + projectName: project.name, + projectId: webhook.projectId, + environment: webhook.environment.slug, + secretPath: webhook.secretPath, + type: webhook.type + } }) ); } catch (err) { diff --git a/backend/src/services/webhook/webhook-types.ts b/backend/src/services/webhook/webhook-types.ts index 40dacb42a..8ce2c8d8e 100644 --- a/backend/src/services/webhook/webhook-types.ts +++ b/backend/src/services/webhook/webhook-types.ts @@ -30,3 +30,36 @@ export enum WebhookType { GENERAL = "general", SLACK = "slack" } + +export enum WebhookEvents { + SecretModified = "secrets.modified", + SecretReminderExpired = "secrets.reminder-expired", + TestEvent = "test" +} + +type TWebhookSecretModifiedEventPayload = { + type: WebhookEvents.SecretModified; + payload: { + projectName?: string; + projectId: string; + environment: string; + secretPath?: string; + type?: string | null; + }; +}; + +type TWebhookSecretReminderEventPayload = { + type: WebhookEvents.SecretReminderExpired; + payload: { + projectName?: string; + projectId: string; + environment: string; + secretPath?: string; + type?: string | null; + secretName: string; + secretId: string; + reminderNote?: string | null; + }; +}; + +export type TWebhookPayloads = TWebhookSecretModifiedEventPayload | TWebhookSecretReminderEventPayload; diff --git a/cli/config/example-infisical-relay.yaml b/cli/config/example-infisical-relay.yaml new file mode 100644 index 000000000..c913ed757 --- /dev/null +++ b/cli/config/example-infisical-relay.yaml @@ -0,0 +1,8 @@ +public_ip: 127.0.0.1 +auth_secret: example-auth-secret +realm: infisical.org +# set port 5349 for tls +# port: 5349 +# tls_private_key_path: /full-path +# tls_ca_path: /full-path +# tls_cert_path: /full-path diff --git a/cli/config/infisical-relay.yaml b/cli/config/infisical-relay.yaml index 1a2f32d21..89c6b5e45 100644 --- a/cli/config/infisical-relay.yaml +++ b/cli/config/infisical-relay.yaml @@ -1,3 +1,8 @@ public_ip: 127.0.0.1 auth_secret: changeThisOnProduction realm: infisical.org +# set port 5349 for tls +# port: 5349 +# tls_private_key_path: /full-path +# tls_ca_path: /full-path +# tls_cert_path: /full-path diff --git a/cli/go.mod b/cli/go.mod index 66c4e61f8..53f348807 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -28,8 +28,9 @@ require ( github.com/rs/zerolog v1.26.1 github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.8.1 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.35.0 + golang.org/x/sys v0.30.0 golang.org/x/term v0.29.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -115,7 +116,6 @@ require ( golang.org/x/net v0.35.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect golang.org/x/time v0.6.0 // indirect golang.org/x/tools v0.30.0 // indirect @@ -139,3 +139,5 @@ require ( ) replace github.com/zalando/go-keyring => github.com/Infisical/go-keyring v1.0.2 + +replace github.com/pion/turn/v4 => github.com/Infisical/turn/v4 v4.0.1 diff --git a/cli/go.sum b/cli/go.sum index e274c0d38..d87cc825a 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -49,6 +49,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Infisical/go-keyring v1.0.2 h1:dWOkI/pB/7RocfSJgGXbXxLDcVYsdslgjEPmVhb+nl8= github.com/Infisical/go-keyring v1.0.2/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= +github.com/Infisical/turn/v4 v4.0.1 h1:omdelNsnFfzS5cu86W5OBR68by68a8sva4ogR0lQQnw= +github.com/Infisical/turn/v4 v4.0.1/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -365,8 +367,6 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= -github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -425,8 +425,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= diff --git a/cli/packages/cmd/gateway.go b/cli/packages/cmd/gateway.go index 760fd95c4..d796ffede 100644 --- a/cli/packages/cmd/gateway.go +++ b/cli/packages/cmd/gateway.go @@ -137,15 +137,10 @@ var gatewayRelayCmd = &cobra.Command{ } func init() { - gatewayCmd.SetHelpFunc(func(command *cobra.Command, strings []string) { - command.Flags().MarkHidden("domain") - command.Parent().HelpFunc()(command, strings) - }) gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path") gatewayCmd.AddCommand(gatewayRelayCmd) - rootCmd.AddCommand(gatewayCmd) } diff --git a/cli/packages/gateway/connection.go b/cli/packages/gateway/connection.go index d8bfebc91..58a0503ff 100644 --- a/cli/packages/gateway/connection.go +++ b/cli/packages/gateway/connection.go @@ -61,7 +61,6 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { switch string(cmd) { case "FORWARD-TCP": - log.Info().Msg("Starting secure connector proxy...") proxyAddress := string(bytes.Split(args, []byte(" "))[0]) destTarget, err := net.Dial("tcp", proxyAddress) if err != nil { @@ -69,6 +68,7 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { return } defer destTarget.Close() + log.Info().Msgf("Starting secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String()) // Handle buffered data buffered := reader.Buffered() @@ -87,6 +87,7 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { } CopyDataFromQuicToTcp(stream, destTarget) + log.Info().Msgf("Ending secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String()) return case "PING": if _, err := stream.Write([]byte("PONG\n")); err != nil { diff --git a/cli/packages/gateway/gateway.go b/cli/packages/gateway/gateway.go index 846231071..248a9dc7b 100644 --- a/cli/packages/gateway/gateway.go +++ b/cli/packages/gateway/gateway.go @@ -6,11 +6,13 @@ import ( "crypto/x509" "fmt" "net" + "os" "strings" "sync" "time" "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/systemd" "github.com/go-resty/resty/v2" "github.com/pion/logging" "github.com/pion/turn/v4" @@ -75,6 +77,10 @@ func (g *Gateway) ConnectWithRelay() error { // Start a new TURN Client and wrap our net.Conn in a STUNConn // This allows us to simulate datagram based communication over a net.Conn + logger := logging.NewDefaultLoggerFactory() + if os.Getenv("LOG_LEVEL") == "debug" { + logger.DefaultLogLevel = logging.LogLevelDebug + } cfg := &turn.ClientConfig{ STUNServerAddr: relayDetails.TurnServerAddress, TURNServerAddr: relayDetails.TurnServerAddress, @@ -82,7 +88,7 @@ func (g *Gateway) ConnectWithRelay() error { Username: relayDetails.TurnServerUsername, Password: relayDetails.TurnServerPassword, Realm: relayDetails.TurnServerRealm, - LoggerFactory: logging.NewDefaultLoggerFactory(), + LoggerFactory: logger, } client, err := turn.NewClient(cfg) @@ -96,10 +102,6 @@ func (g *Gateway) ConnectWithRelay() error { TurnServerAddress: relayDetails.TurnServerAddress, InfisicalStaticIp: relayDetails.InfisicalStaticIp, } - // if port not specific allow all port - if relayDetails.InfisicalStaticIp != "" && !strings.Contains(relayDetails.InfisicalStaticIp, ":") { - g.config.InfisicalStaticIp = g.config.InfisicalStaticIp + ":0" - } g.client = client return nil @@ -144,7 +146,10 @@ func (g *Gateway) Listen(ctx context.Context) error { errCh := make(chan error, 1) shutdownCh := make(chan bool, 1) - g.registerPermissionRefresh(ctx, errCh) + if err = g.createPermissionForStaticIps(g.config.InfisicalStaticIp); err != nil { + return err + } + g.registerHeartBeat(ctx, errCh) cert, err := tls.X509KeyPair([]byte(gatewayCert.Certificate), []byte(gatewayCert.PrivateKey)) @@ -171,8 +176,7 @@ func (g *Gateway) Listen(ctx context.Context) error { KeepAlivePeriod: 2 * time.Second, } - g.registerRelayIsActive(ctx, relayUdpConnection.LocalAddr().String(), tlsConfig, quicConfig, errCh) - + g.registerRelayIsActive(ctx, errCh) quicListener, err := quic.Listen(relayUdpConnection, tlsConfig, quicConfig) if err != nil { return fmt.Errorf("Failed to listen for QUIC: %w", err) @@ -234,6 +238,8 @@ func (g *Gateway) Listen(ctx context.Context) error { } }() + // make this compatiable with systemd notify mode + systemd.SdNotify(false, systemd.SdNotifyReady) select { case <-ctx.Done(): log.Info().Msg("Shutting down gateway...") @@ -282,90 +288,86 @@ func (g *Gateway) registerHeartBeat(ctx context.Context, errCh chan error) { }() } -func (g *Gateway) registerRelayIsActive(ctx context.Context, serverAddr string, tlsConf *tls.Config, quicConf *quic.Config, errCh chan error) { - ticker := time.NewTicker(5 * time.Second) +func (g *Gateway) createPermissionForStaticIps(staticIps string) error { + if staticIps == "" { + return fmt.Errorf("Missing Infisical static ips for permission") + } + + splittedIps := strings.Split(staticIps, ",") + resolvedIps := make([]net.Addr, 0) + for _, ip := range splittedIps { + ip = strings.TrimSpace(ip) + if ip == "" { + continue + } + + // if port not specific allow all port + if !strings.Contains(ip, ":") { + ip = ip + ":0" + } + + peerAddr, err := net.ResolveUDPAddr("udp", ip) + if err != nil { + return fmt.Errorf("Failed to resolve static ip for permission: %w", err) + } + + resolvedIps = append(resolvedIps, peerAddr) + } + + if err := g.client.CreatePermission(resolvedIps...); err != nil { + return fmt.Errorf("Failed to set ip permission: %w", err) + } + return nil +} + +func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) error { + ticker := time.NewTicker(15 * time.Second) maxFailures := 3 failures := 0 + log.Info().Msg("Starting relay connection health check") + go func() { - time.Sleep(2 * time.Second) + time.Sleep(5 * time.Second) for { select { case <-ctx.Done(): + log.Info().Msg("Stopping relay connection health check") return case <-ticker.C: - conn, err := quic.DialAddr(ctx, serverAddr, tlsConf, quicConf) - if conn != nil { - failures = 0 - conn.CloseWithError(0, "connection closed") - } + func() { + log.Debug().Msg("Performing relay connection health check") - if err != nil && !strings.Contains(err.Error(), "tls: failed to verify certificate") { - failures++ - log.Warn().Err(err).Int("failures", failures).Msg("Relay connection check failed") - - if failures >= maxFailures { - errCh <- fmt.Errorf("relay connection check failed: %w", err) + if g.client == nil { + failures++ + log.Warn().Int("failures", failures).Msg("TURN client is nil") + if failures >= maxFailures { + errCh <- fmt.Errorf("relay connection check failed: TURN client is nil") + } + return } - } + + // we try to refresh permissions - this is a lightweight operation + // that will fail immediately if the UDP connection is broken. good for health check + log.Debug().Msg("Refreshing TURN permissions to verify connection") + if err := g.createPermissionForStaticIps(g.config.InfisicalStaticIp); err != nil { + failures++ + log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions") + if failures >= maxFailures { + errCh <- fmt.Errorf("relay connection check failed: %w", err) + } + return + } + + log.Debug().Msg("Successfully refreshed TURN permissions - connection is healthy") + if failures > 0 { + log.Info().Int("previous_failures", failures).Msg("Relay connection restored") + failures = 0 + } + }() } } }() -} - -func (g *Gateway) registerPermissionRefresh(ctx context.Context, errCh chan error) { - if g.config.InfisicalStaticIp == "" { - return - } - - log.Info().Msg("Starting TURN permission refresh routine") - - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - g.refreshPermission(errCh) - - for { - select { - case <-ctx.Done(): - log.Info().Msg("Context cancelled, stopping TURN permission refresh") - return - case <-ticker.C: - g.refreshPermission(errCh) - } - } - }() -} - -func (g *Gateway) refreshPermission(errCh chan error) { - log.Info().Msg("Attempting to refresh TURN permission") - maxRetries := 3 - retryDelay := 5 * time.Second - - var lastErr error - for i := 0; i < maxRetries; i++ { - peerAddr, err := net.ResolveUDPAddr("udp", g.config.InfisicalStaticIp) - if err != nil { - log.Error().Err(err).Msg("Failed to resolve static IP for permission refresh") - continue - } - - if err := g.client.CreatePermission(peerAddr); err != nil { - lastErr = err - log.Warn().Err(err).Int("attempt", i+1).Msg("Failed to refresh TURN permission, retrying...") - time.Sleep(retryDelay) - continue - } - - log.Info().Msg("Successfully refreshed TURN permission") - return - } - - if lastErr != nil { - log.Error().Err(lastErr).Msg("Failed to refresh TURN permission after retries") - if reconnectErr := g.ConnectWithRelay(); reconnectErr != nil { - errCh <- fmt.Errorf("failed to refresh permissions and reconnect: %w", reconnectErr) - } - } + + return nil } diff --git a/cli/packages/gateway/relay.go b/cli/packages/gateway/relay.go index 52636d2d3..bbd1332a4 100644 --- a/cli/packages/gateway/relay.go +++ b/cli/packages/gateway/relay.go @@ -1,8 +1,12 @@ +//go:build !windows +// +build !windows + package gateway import ( "context" "crypto/tls" + "crypto/x509" "errors" "fmt" "net" @@ -13,6 +17,7 @@ import ( "syscall" udplistener "github.com/Infisical/infisical-merge/packages/gateway/udp_listener" + "github.com/Infisical/infisical-merge/packages/systemd" "github.com/pion/logging" "github.com/pion/turn/v4" "github.com/rs/zerolog/log" @@ -36,8 +41,10 @@ type GatewayRelayConfig struct { RelayMaxPort uint16 `yaml:"relay_max_port"` TlsCertPath string `yaml:"tls_cert_path"` TlsPrivateKeyPath string `yaml:"tls_private_key_path"` + TlsCaPath string `yaml:"tls_ca_path"` tls tls.Certificate + tlsCa string isTlsEnabled bool } @@ -78,19 +85,19 @@ func NewGatewayRelay(configFilePath string) (*GatewayRelay, error) { return nil, errMissingTlsCert } - tlsCertFile, err := os.ReadFile(cfg.TlsCertPath) + cert, err := tls.LoadX509KeyPair(cfg.TlsCertPath, cfg.TlsPrivateKeyPath) if err != nil { - return nil, err - } - tlsPrivateKeyFile, err := os.ReadFile(cfg.TlsPrivateKeyPath) - if err != nil { - return nil, err + return nil, fmt.Errorf("Failed to read load server tls key pair: %w", err) } - cert, err := tls.LoadX509KeyPair(string(tlsCertFile), string(tlsPrivateKeyFile)) - if err != nil { - return nil, err + if cfg.TlsCaPath != "" { + ca, err := os.ReadFile(cfg.TlsCaPath) + if err != nil { + return nil, fmt.Errorf("Failed to read tls ca: %w", err) + } + cfg.tlsCa = string(ca) } + cfg.tls = cert cfg.isTlsEnabled = true } @@ -139,8 +146,12 @@ func (g *GatewayRelay) Run() error { } if g.Config.isTlsEnabled { + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa)) + listenerConfigs[i].Listener = tls.NewListener(conn, &tls.Config{ Certificates: []tls.Certificate{g.Config.tls}, + ClientCAs: caCertPool, }) } else { listenerConfigs[i].Listener = conn @@ -164,6 +175,9 @@ func (g *GatewayRelay) Run() error { } log.Info().Msgf("Relay listening on %s\n", connAddress) + + // make this compatiable with systemd notify mode + systemd.SdNotify(false, systemd.SdNotifyReady) // Block until user sends SIGINT or SIGTERM sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) diff --git a/cli/packages/gateway/relay_windows.go b/cli/packages/gateway/relay_windows.go new file mode 100644 index 000000000..f3bf89bd0 --- /dev/null +++ b/cli/packages/gateway/relay_windows.go @@ -0,0 +1,37 @@ +//go:build windows +// +build windows + +package gateway + +import ( + "errors" +) + +var ( + errMissingTlsCert = errors.New("Missing TLS files") + errWindowsNotSupported = errors.New("Relay is not supported on Windows") +) + +type GatewayRelay struct { + Config *GatewayRelayConfig +} + +type GatewayRelayConfig struct { + PublicIP string + Port int + Realm string + AuthSecret string + RelayMinPort uint16 + RelayMaxPort uint16 + TlsCertPath string + TlsPrivateKeyPath string + TlsCaPath string +} + +func NewGatewayRelay(configFilePath string) (*GatewayRelay, error) { + return nil, errWindowsNotSupported +} + +func (g *GatewayRelay) Run() error { + return errWindowsNotSupported +} diff --git a/cli/packages/systemd/daemon.go b/cli/packages/systemd/daemon.go new file mode 100644 index 000000000..ce3c97394 --- /dev/null +++ b/cli/packages/systemd/daemon.go @@ -0,0 +1,84 @@ +// Copyright 2014 Docker, Inc. +// Copyright 2015-2018 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// Package daemon provides a Go implementation of the sd_notify protocol. +// It can be used to inform systemd of service start-up completion, watchdog +// events, and other status changes. +// +// https://www.freedesktop.org/software/systemd/man/sd_notify.html#Description +package systemd + +import ( + "net" + "os" +) + +const ( + // SdNotifyReady tells the service manager that service startup is finished + // or the service finished loading its configuration. + SdNotifyReady = "READY=1" + + // SdNotifyStopping tells the service manager that the service is beginning + // its shutdown. + SdNotifyStopping = "STOPPING=1" + + // SdNotifyReloading tells the service manager that this service is + // reloading its configuration. Note that you must call SdNotifyReady when + // it completed reloading. + SdNotifyReloading = "RELOADING=1" + + // SdNotifyWatchdog tells the service manager to update the watchdog + // timestamp for the service. + SdNotifyWatchdog = "WATCHDOG=1" +) + +// SdNotify sends a message to the init daemon. It is common to ignore the error. +// If `unsetEnvironment` is true, the environment variable `NOTIFY_SOCKET` +// will be unconditionally unset. +// +// It returns one of the following: +// (false, nil) - notification not supported (i.e. NOTIFY_SOCKET is unset) +// (false, err) - notification supported, but failure happened (e.g. error connecting to NOTIFY_SOCKET or while sending data) +// (true, nil) - notification supported, data has been sent +func SdNotify(unsetEnvironment bool, state string) (bool, error) { + socketAddr := &net.UnixAddr{ + Name: os.Getenv("NOTIFY_SOCKET"), + Net: "unixgram", + } + + // NOTIFY_SOCKET not set + if socketAddr.Name == "" { + return false, nil + } + + if unsetEnvironment { + if err := os.Unsetenv("NOTIFY_SOCKET"); err != nil { + return false, err + } + } + + conn, err := net.DialUnix(socketAddr.Net, nil, socketAddr) + // Error connecting to NOTIFY_SOCKET + if err != nil { + return false, err + } + defer conn.Close() + + if _, err = conn.Write([]byte(state)); err != nil { + return false, err + } + return true, nil +} diff --git a/docs/documentation/platform/webhooks.mdx b/docs/documentation/platform/webhooks.mdx index dc3a71b27..92d3ff8b8 100644 --- a/docs/documentation/platform/webhooks.mdx +++ b/docs/documentation/platform/webhooks.mdx @@ -36,3 +36,18 @@ If the signature in the header matches the signature that you generated, then yo "timestamp": "" } ``` + +```json +{ + "event": "secrets.reminder-expired", + "project": { + "workspaceId": "the workspace id", + "environment": "project environment", + "secretPath": "project folder path", + "secretName": "name of the secret", + "secretId": "id of the secret", + "reminderNote": "reminder note of the secret" + }, + "timestamp": "" +} +``` diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 39c351e38..d348c7bb1 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -122,6 +122,7 @@ export const eventToNameMap: { [K in EventType]: string } = { "OIDC group membership mapping assigned user to groups", [EventType.OIDC_GROUP_MEMBERSHIP_MAPPING_REMOVE_USER]: "OIDC group membership mapping removed user from groups", + [EventType.SECRET_APPROVAL_REQUEST_REVIEW]: "Review Secret Approval Request", [EventType.CREATE_KMIP_CLIENT]: "Create KMIP client", [EventType.UPDATE_KMIP_CLIENT]: "Update KMIP client", [EventType.DELETE_KMIP_CLIENT]: "Delete KMIP client", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index ed25c6e4a..76bb283dc 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -150,5 +150,6 @@ export enum EventType { KMIP_OPERATION_ACTIVATE = "kmip-operation-activate", KMIP_OPERATION_REVOKE = "kmip-operation-revoke", KMIP_OPERATION_LOCATE = "kmip-operation-locate", - KMIP_OPERATION_REGISTER = "kmip-operation-register" + KMIP_OPERATION_REGISTER = "kmip-operation-register", + SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review" } diff --git a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx index ce584a85f..78e1b37a1 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/mutation.tsx @@ -13,9 +13,10 @@ export const useUpdateSecretApprovalReviewStatus = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ id, status }) => { + mutationFn: async ({ id, status, comment }) => { const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/review`, { - status + status, + comment }); return data; }, diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index a03526dae..433d82855 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -44,6 +44,7 @@ export type TSecretApprovalRequest = { reviewers: { userId: string; status: ApprovalStatus; + comment: string; email: string; firstName: string; lastName: string; @@ -114,6 +115,7 @@ export type TGetSecretApprovalRequestDetails = { export type TUpdateSecretApprovalReviewStatusDTO = { status: ApprovalStatus; + comment?: string; id: string; }; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 92dc220b8..9e0a82dda 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -101,6 +101,12 @@ export type SecretVersions = { skipMultilineEncoding?: boolean; createdAt: string; updatedAt: string; + actor?: { + actorId?: string | null; + actorType?: string | null; + name?: string | null; + membershipId?: string | null; + } | null; }; // dto diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 04bee3042..aa73d7d07 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -1,19 +1,36 @@ import { ReactNode } from "react"; +import { Controller, useForm } from "react-hook-form"; import { + faAngleDown, faArrowLeft, - faCheck, faCheckCircle, faCircle, faCodeBranch, + faComment, faFolder, faXmarkCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from "@radix-ui/react-radio-group"; import { twMerge } from "tailwind-merge"; +import z from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, ContentLoader, EmptyState, IconButton, Tooltip } from "@app/components/v2"; +import { + Button, + ContentLoader, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + EmptyState, + FormControl, + IconButton, + TextArea, + Tooltip +} from "@app/components/v2"; import { useUser } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { useGetSecretApprovalRequestDetails, useUpdateSecretApprovalReviewStatus @@ -74,6 +91,13 @@ type Props = { onGoBack: () => void; }; +const reviewFormSchema = z.object({ + comment: z.string().trim().optional().default(""), + status: z.nativeEnum(ApprovalStatus) +}); + +type TReviewFormSchema = z.infer; + export const SecretApprovalRequestChanges = ({ approvalRequestId, onGoBack, @@ -94,6 +118,16 @@ export const SecretApprovalRequestChanges = ({ variables } = useUpdateSecretApprovalReviewStatus(); + const { popUp, handlePopUpToggle } = usePopUp(["reviewChanges"] as const); + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(reviewFormSchema) + }); + const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus; const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus; @@ -101,23 +135,23 @@ export const SecretApprovalRequestChanges = ({ const canApprove = secretApprovalRequestDetails?.policy?.approvers?.some( ({ userId }) => userId === userSession.id ); + const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce< - Record + Record >( (prev, curr) => ({ ...prev, - [curr.userId]: curr.status + [curr.userId]: { status: curr.status, comment: curr.comment } }), {} ); - const hasApproved = reviewedUsers?.[userSession.id] === ApprovalStatus.APPROVED; - const hasRejected = reviewedUsers?.[userSession.id] === ApprovalStatus.REJECTED; - const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => { + const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus, comment: string) => { try { await updateSecretApprovalRequestStatus({ id: approvalRequestId, - status + status, + comment }); createNotification({ type: "success", @@ -130,6 +164,16 @@ export const SecretApprovalRequestChanges = ({ text: "Failed to update the request status" }); } + + handlePopUpToggle("reviewChanges", false); + reset({ + comment: "", + status: ApprovalStatus.APPROVED + }); + }; + + const handleSubmitReview = (data: TReviewFormSchema) => { + handleSecretApprovalStatusUpdate(data.status, data.comment); }; if (isSecretApprovalRequestLoading) { @@ -150,7 +194,7 @@ export const SecretApprovalRequestChanges = ({ const isMergable = secretApprovalRequestDetails?.policy?.approvals <= secretApprovalRequestDetails?.policy?.approvers?.filter( - ({ userId }) => reviewedUsers?.[userId] === ApprovalStatus.APPROVED + ({ userId }) => reviewedUsers?.[userId]?.status === ApprovalStatus.APPROVED ).length; const hasMerged = secretApprovalRequestDetails?.hasMerged; @@ -202,27 +246,115 @@ export const SecretApprovalRequestChanges = ({ {!hasMerged && secretApprovalRequestDetails.status === "open" && ( - <> - - - + handlePopUpToggle("reviewChanges", isOpen)} + > + + + + +
+
+
Finish your review
+ ( + + + + )} +
+ ); + })} + +
Reviewers
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => { - const status = reviewedUsers?.[requiredApprover.userId]; + const reviewer = reviewedUsers?.[requiredApprover.userId]; return (
*
- - {getReviewedStatusSymbol(status)} + {reviewer?.comment && ( + + + + )} + + {getReviewedStatusSymbol(reviewer?.status)}
@@ -290,7 +464,7 @@ export const SecretApprovalRequestChanges = ({ ) ) .map((reviewer) => { - const status = reviewedUsers?.[reviewer.userId]; + const status = reviewedUsers?.[reviewer.userId].status; return (
*
+ {reviewer.comment && ( + + + + )} {getReviewedStatusSymbol(status)} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 168cd6f43..5e7af286d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -5,15 +5,19 @@ import { faArrowRotateRight, faCheckCircle, faClock, + faCopy, + faDesktop, faEyeSlash, faPlus, + faServer, faShare, faTag, - faTrash + faTrash, + faUser } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { format } from "date-fns"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -46,6 +50,7 @@ import { } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useGetSecretVersion } from "@app/hooks/api"; +import { ActorType } from "@app/hooks/api/auditLogs/enums"; import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries"; import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { ProjectType } from "@app/hooks/api/workspace/types"; @@ -120,6 +125,7 @@ export const SecretDetailSidebar = ({ {} ); const selectTagSlugs = selectedTags.map((i) => i.slug); + const navigate = useNavigate(); const cannotEditSecret = permission.cannot( ProjectPermissionActions.Edit, @@ -192,15 +198,73 @@ export const SecretDetailSidebar = ({ await onSaveSecret(secret, { ...secret, ...data }, () => reset()); }; - const handleReminderSubmit = async (reminderRepeatDays: number | null | undefined, reminderNote: string | null | undefined) => { - await onSaveSecret(secret, { ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true }, () => { }); - } + const handleReminderSubmit = async ( + reminderRepeatDays: number | null | undefined, + reminderNote: string | null | undefined + ) => { + await onSaveSecret( + secret, + { ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true }, + () => {} + ); + }; const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false); const secretReminderRepeatDays = watch("reminderRepeatDays"); const secretReminderNote = watch("reminderNote"); + const getModifiedByIcon = (userType: string | undefined | null) => { + switch (userType) { + case ActorType.USER: + return faUser; + case ActorType.IDENTITY: + return faDesktop; + default: + return faServer; + } + }; + + const getModifiedByName = ( + userType: string | undefined | null, + userName: string | null | undefined + ) => { + switch (userType) { + case ActorType.PLATFORM: + return "System-generated"; + default: + return userName; + } + }; + + const getLinkToModifyHistoryEntity = ( + actorId: string, + actorType: string, + membershipId: string | null = "" + ) => { + switch (actorType) { + case ActorType.USER: + return `/${ProjectType.SecretManager}/${currentWorkspace.id}/members/${membershipId}`; + case ActorType.IDENTITY: + return `/${ProjectType.SecretManager}/${currentWorkspace.id}/identities/${actorId}`; + default: + return null; + } + }; + + const onModifyHistoryClick = ( + actorId: string | undefined | null, + actorType: string | undefined | null, + membershipId: string | undefined | null + ) => { + if (actorType && actorId && actorType !== ActorType.PLATFORM) { + const redirectLink = getLinkToModifyHistoryEntity(actorId, actorType, membershipId); + if (redirectLink) { + navigate({ to: redirectLink }); + } + } + }; + return ( <> @@ -618,7 +682,7 @@ export const SecretDetailSidebar = ({
Version History
- {secretVersion?.map(({ createdAt, secretValue, version, id }) => ( + {secretVersion?.map(({ createdAt, secretValue, version, id, actor }) => (
@@ -633,36 +697,42 @@ export const SecretDetailSidebar = ({
-
-
- Value: -
-
-
- - + -
- - {secretValue?.replace(/./g, "*")} - +
+ + {secretValue?.replace(/./g, "*")} + - + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.currentTarget + .closest(".group") + ?.classList.add("show-value"); + } + }} + > + + + +
@@ -898,29 +991,49 @@ export const SecretDetailSidebar = ({ )} - - {(isAllowed) => ( +
+ { + await navigator.clipboard.writeText(secret.id); + + createNotification({ + title: "Secret ID Copied", + text: "The secret ID has been copied to your clipboard.", + type: "success" + }); + }} > - - - + - )} - + + + {(isAllowed) => ( + + + + + + )} + +
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx index ec765a361..e817aa1f9 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretListView.tsx @@ -238,10 +238,12 @@ export const SecretListView = ({ if (!isReminderEvent) { handlePopUpClose("secretDetail"); } - + let successMessage; if (isReminderEvent) { - successMessage = reminderRepeatDays ? "Successfully saved secret reminder" : "Successfully deleted secret reminder"; + successMessage = reminderRepeatDays + ? "Successfully saved secret reminder" + : "Successfully deleted secret reminder"; } else { successMessage = "Successfully saved secrets"; }