Merge remote-tracking branch 'origin/main' into misc/privilege-management-v2-transition

This commit is contained in:
Sheen Capadngan
2025-03-08 03:40:33 +08:00
52 changed files with 1386 additions and 335 deletions
@@ -35,7 +35,20 @@ jobs:
echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env
echo "SECRET_SCANNING_PRIVATE_KEY=some-random" >> .env echo "SECRET_SCANNING_PRIVATE_KEY=some-random" >> .env
echo "SECRET_SCANNING_WEBHOOK_SECRET=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: env:
REDIS_URL: redis://172.17.0.1:6379 REDIS_URL: redis://172.17.0.1:6379
DB_CONNECTION_URI: postgres://infisical:[email protected]:5432/infisical?sslmode=disable DB_CONNECTION_URI: postgres://infisical:[email protected]:5432/infisical?sslmode=disable
@@ -49,21 +62,33 @@ jobs:
SECONDS=0 SECONDS=0
HEALTHY=0 HEALTHY=0
while [ $SECONDS -lt 60 ]; do while [ $SECONDS -lt 60 ]; do
if docker ps | grep infisical-api | grep -q healthy; then # Check if container is running
echo "Container is healthy." if docker ps | grep infisical-api; then
HEALTHY=1 # 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 break
fi fi
echo "Waiting for container to be healthy... ($SECONDS seconds elapsed)" echo "Waiting for container to be healthy... ($SECONDS seconds elapsed)"
sleep 5
docker logs infisical-api SECONDS=$((SECONDS+5))
sleep 2
SECONDS=$((SECONDS+2))
done done
if [ $HEALTHY -ne 1 ]; then if [ $HEALTHY -ne 1 ]; then
echo "Container did not become healthy in time" 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 exit 1
fi fi
- name: Install openapi-diff - name: Install openapi-diff
@@ -71,7 +96,8 @@ jobs:
- name: Running OpenAPI Spec diff action - 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 run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR
- name: cleanup - name: cleanup
if: always()
run: | run: |
docker compose -f "docker-compose.dev.yml" down docker compose -f "docker-compose.dev.yml" down
docker stop infisical-api docker stop infisical-api || true
docker remove infisical-api docker rm infisical-api || true
@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
if (await knex.schema.hasColumn(TableName.SecretApprovalRequestReviewer, "comment")) {
await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (t) => {
t.dropColumn("comment");
});
}
}
@@ -0,0 +1,45 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
}
});
}
}
@@ -13,7 +13,8 @@ export const SecretApprovalRequestsReviewersSchema = z.object({
requestId: z.string().uuid(), requestId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
reviewerUserId: z.string().uuid() reviewerUserId: z.string().uuid(),
comment: z.string().nullable().optional()
}); });
export type TSecretApprovalRequestsReviewers = z.infer<typeof SecretApprovalRequestsReviewersSchema>; export type TSecretApprovalRequestsReviewers = z.infer<typeof SecretApprovalRequestsReviewersSchema>;
+4 -1
View File
@@ -25,7 +25,10 @@ export const SecretVersionsV2Schema = z.object({
folderId: z.string().uuid(), folderId: z.string().uuid(),
userId: z.string().uuid().nullable().optional(), userId: z.string().uuid().nullable().optional(),
createdAt: z.date(), 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<typeof SecretVersionsV2Schema>; export type TSecretVersionsV2 = z.infer<typeof SecretVersionsV2Schema>;
@@ -159,7 +159,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
id: z.string() id: z.string()
}), }),
body: z.object({ body: z.object({
status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]),
comment: z.string().optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -175,8 +176,25 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
approvalId: req.params.id, 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 }; return { review };
} }
}); });
@@ -267,7 +285,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
environment: z.string(), environment: z.string(),
statusChangedByUser: approvalRequestUser.optional(), statusChangedByUser: approvalRequestUser.optional(),
committerUser: approvalRequestUser, committerUser: approvalRequestUser,
reviewers: approvalRequestUser.extend({ status: z.string() }).array(), reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(),
secretPath: z.string(), secretPath: z.string(),
commits: secretRawSchema commits: secretRawSchema
.omit({ _id: true, environment: true, workspace: true, type: true, version: true }) .omit({ _id: true, environment: true, workspace: true, type: true, version: true })
@@ -22,6 +22,7 @@ import {
} from "@app/services/secret-sync/secret-sync-types"; } from "@app/services/secret-sync/secret-sync-types";
import { KmipPermission } from "../kmip/kmip-enum"; import { KmipPermission } from "../kmip/kmip-enum";
import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types";
export type TListProjectAuditLogDTO = { export type TListProjectAuditLogDTO = {
filter: { filter: {
@@ -165,6 +166,7 @@ export enum EventType {
SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_REQUEST = "secret-approval-request",
SECRET_APPROVAL_CLOSED = "secret-approval-closed", SECRET_APPROVAL_CLOSED = "secret-approval-closed",
SECRET_APPROVAL_REOPENED = "secret-approval-reopened", SECRET_APPROVAL_REOPENED = "secret-approval-reopened",
SECRET_APPROVAL_REQUEST_REVIEW = "secret-approval-request-review",
SIGN_SSH_KEY = "sign-ssh-key", SIGN_SSH_KEY = "sign-ssh-key",
ISSUE_SSH_CREDS = "issue-ssh-creds", ISSUE_SSH_CREDS = "issue-ssh-creds",
CREATE_SSH_CA = "create-ssh-certificate-authority", 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 { interface SignSshKey {
type: EventType.SIGN_SSH_KEY; type: EventType.SIGN_SSH_KEY;
metadata: { metadata: {
@@ -2482,4 +2494,5 @@ export type Event =
| KmipOperationRevokeEvent | KmipOperationRevokeEvent
| KmipOperationLocateEvent | KmipOperationLocateEvent
| KmipOperationRegisterEvent | KmipOperationRegisterEvent
| CreateSecretRequestEvent; | CreateSecretRequestEvent
| SecretApprovalRequestReview;
@@ -100,6 +100,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("lastName").withSchema("committerUser").as("committerUserLastName"), tx.ref("lastName").withSchema("committerUser").as("committerUserLastName"),
tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer), tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"), 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("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"),
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
@@ -162,8 +163,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
reviewerEmail: email, reviewerEmail: email,
reviewerLastName: lastName, reviewerLastName: lastName,
reviewerUsername: username, reviewerUsername: username,
reviewerFirstName: firstName reviewerFirstName: firstName,
}) => (userId ? { userId, status, email, firstName, lastName, username } : undefined) reviewerComment: comment
}) =>
userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined
}, },
{ {
key: "approverUserId", key: "approverUserId",
@@ -320,6 +320,7 @@ export const secretApprovalRequestServiceFactory = ({
approvalId, approvalId,
actor, actor,
status, status,
comment,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
@@ -372,15 +373,18 @@ export const secretApprovalRequestServiceFactory = ({
return secretApprovalRequestReviewerDAL.create( return secretApprovalRequestReviewerDAL.create(
{ {
status, status,
comment,
requestId: secretApprovalRequest.id, requestId: secretApprovalRequest.id,
reviewerUserId: actorId reviewerUserId: actorId
}, },
tx 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 ({ const updateApprovalStatus = async ({
@@ -499,7 +503,7 @@ export const secretApprovalRequestServiceFactory = ({
if (!hasMinApproval && !isSoftEnforcement) if (!hasMinApproval && !isSoftEnforcement)
throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); 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; let mergeStatus;
if (shouldUseSecretV2Bridge) { if (shouldUseSecretV2Bridge) {
// this cycle if for bridged secrets // this cycle if for bridged secrets
@@ -857,7 +861,6 @@ export const secretApprovalRequestServiceFactory = ({
if (isSoftEnforcement) { if (isSoftEnforcement) {
const cfg = getConfig(); const cfg = getConfig();
const project = await projectDAL.findProjectById(projectId);
const env = await projectEnvDAL.findOne({ id: policy.envId }); const env = await projectEnvDAL.findOne({ id: policy.envId });
const requestedByUser = await userDAL.findOne({ id: actorId }); const requestedByUser = await userDAL.findOne({ id: actorId });
const approverUsers = await userDAL.find({ const approverUsers = await userDAL.find({
@@ -1152,7 +1155,8 @@ export const secretApprovalRequestServiceFactory = ({
environment: env.name, environment: env.name,
secretPath, secretPath,
projectId, 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, environment: env.name,
secretPath, secretPath,
projectId, projectId,
requestId: secretApprovalRequest.id requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))]
} }
} }
}); });
@@ -80,6 +80,7 @@ export type TStatusChangeDTO = {
export type TReviewRequestDTO = { export type TReviewRequestDTO = {
approvalId: string; approvalId: string;
status: ApprovalStatus; status: ApprovalStatus;
comment?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TApprovalRequestCountDTO = TProjectPermission; export type TApprovalRequestCountDTO = TProjectPermission;
@@ -13,6 +13,7 @@ import { NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
@@ -332,6 +333,7 @@ export const secretRotationQueueFactory = ({
await secretVersionV2BridgeDAL.insertMany( await secretVersionV2BridgeDAL.insertMany(
updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({ updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el, ...el,
actorType: ActorType.PLATFORM,
secretId: id secretId: id
})), })),
tx tx
@@ -7,6 +7,7 @@ import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { InternalServerError, NotFoundError } from "@app/lib/errors"; import { InternalServerError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn"; import { groupBy } from "@app/lib/fn";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
@@ -370,7 +371,21 @@ export const secretSnapshotServiceFactory = ({
const secrets = await secretV2BridgeDAL.insertMany( const secrets = await secretV2BridgeDAL.insertMany(
rollbackSnaps.flatMap(({ secretVersions, folderId }) => rollbackSnaps.flatMap(({ secretVersions, folderId }) =>
secretVersions.map( 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, ...el,
id: secretId, id: secretId,
version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion,
@@ -401,8 +416,18 @@ export const secretSnapshotServiceFactory = ({
})), })),
tx 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( 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 tx
); );
await secretVersionV2TagBridgeDAL.insertMany( await secretVersionV2TagBridgeDAL.insertMany(
+3 -1
View File
@@ -2,7 +2,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import net from "node:net"; import net from "node:net";
import * as quic from "@infisical/quic"; import quicDefault, * as quicModule from "@infisical/quic";
import { BadRequestError } from "../errors"; import { BadRequestError } from "../errors";
import { logger } from "../logger"; import { logger } from "../logger";
@@ -10,6 +10,8 @@ import { logger } from "../logger";
const DEFAULT_MAX_RETRIES = 3; const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_RETRY_DELAY = 1000; // 1 second const DEFAULT_RETRY_DELAY = 1000; // 1 second
const quic = quicDefault || quicModule;
const parseSubjectDetails = (data: string) => { const parseSubjectDetails = (data: string) => {
const values: Record<string, string> = {}; const values: Record<string, string> = {};
data.split("\n").forEach((el) => { data.split("\n").forEach((el) => {
+8
View File
@@ -83,6 +83,14 @@ const run = async () => {
process.exit(0); 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({ await server.listen({
port: envConfig.PORT, port: envConfig.PORT,
host: envConfig.HOST, host: envConfig.HOST,
+2 -1
View File
@@ -21,6 +21,7 @@ import {
TQueueSecretSyncSyncSecretsByIdDTO, TQueueSecretSyncSyncSecretsByIdDTO,
TQueueSendSecretSyncActionFailedNotificationsDTO TQueueSendSecretSyncActionFailedNotificationsDTO
} from "@app/services/secret-sync/secret-sync-types"; } from "@app/services/secret-sync/secret-sync-types";
import { TWebhookPayloads } from "@app/services/webhook/webhook-types";
export enum QueueName { export enum QueueName {
SecretRotation = "secret-rotation", SecretRotation = "secret-rotation",
@@ -107,7 +108,7 @@ export type TQueueJobTypes = {
}; };
[QueueName.SecretWebhook]: { [QueueName.SecretWebhook]: {
name: QueueJobs.SecWebhook; name: QueueJobs.SecWebhook;
payload: { projectId: string; environment: string; secretPath: string; depth?: number }; payload: TWebhookPayloads;
}; };
[QueueName.AccessTokenStatusUpdate]: [QueueName.AccessTokenStatusUpdate]:
+10 -1
View File
@@ -111,7 +111,16 @@ export const secretRawSchema = z.object({
secretReminderRepeatDays: z.number().nullable().optional(), secretReminderRepeatDays: z.number().nullable().optional(),
skipMultilineEncoding: z.boolean().default(false).nullable().optional(), skipMultilineEncoding: z.boolean().default(false).nullable().optional(),
createdAt: z.date(), 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({ export const ProjectPermissionSchema = z.object({
@@ -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({ server.route({
method: "GET", method: "GET",
url: "/raw/:secretName", url: "/raw/:secretName",
@@ -772,6 +772,10 @@ export const importDataIntoInfisicalFn = async ({
secretVersionDAL, secretVersionDAL,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor: {
type: actor,
actorId
},
tx tx
}); });
} }
@@ -114,20 +114,27 @@ export const integrationAuthServiceFactory = ({
const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => { const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => {
const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string); const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string);
return Promise.all( const filteredAuthorizations = await Promise.all(
authorizations.filter(async (auth) => { authorizations.map(async (auth) => {
const { permission } = await permissionService.getProjectPermission({ try {
actor, const { permission } = await permissionService.getProjectPermission({
actorId, actor,
projectId: auth.projectId, actorId,
actorAuthMethod, projectId: auth.projectId,
actorOrgId, actorAuthMethod,
actionProjectType: ActionProjectType.SecretManager 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<typeof auth> => auth !== null);
}; };
const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => { const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => {
@@ -613,6 +613,9 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${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`) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
.select(selectAllTableCols(TableName.SecretV2)) .select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .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("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
); )
.select(db.ref("projectId").withSchema(TableName.Environment).as("projectId"));
const docs = sqlNestRelationships({ const docs = sqlNestRelationships({
data: rawDocs, data: rawDocs,
key: "id", key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), parentMapper: (el) => ({ _id: el.id, projectId: el.projectId, ...SecretsV2Schema.parse(el) }),
childrenMapper: [ childrenMapper: [
{ {
key: "tagId", key: "tagId",
@@ -5,6 +5,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn"; import { groupBy } from "@app/lib/fn";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { ActorType } from "../auth/auth-type";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
@@ -62,6 +63,7 @@ export const fnSecretBulkInsert = async ({
resourceMetadataDAL, resourceMetadataDAL,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor,
tx tx
}: TFnSecretBulkInsert) => { }: TFnSecretBulkInsert) => {
const sanitizedInputSecrets = inputSecrets.map( 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( const newSecrets = await secretDAL.insertMany(
sanitizedInputSecrets.map((el) => ({ ...el, folderId })), sanitizedInputSecrets.map((el) => ({ ...el, folderId })),
tx tx
@@ -106,6 +112,9 @@ export const fnSecretBulkInsert = async ({
sanitizedInputSecrets.map((el) => ({ sanitizedInputSecrets.map((el) => ({
...el, ...el,
folderId, folderId,
userActorId,
identityActorId,
actorType,
secretId: newSecretGroupedByKeyName[el.key][0].id secretId: newSecretGroupedByKeyName[el.key][0].id
})), })),
tx tx
@@ -157,8 +166,13 @@ export const fnSecretBulkUpdate = async ({
secretVersionDAL, secretVersionDAL,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
resourceMetadataDAL resourceMetadataDAL,
actor
}: TFnSecretBulkUpdate) => { }: 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( const sanitizedInputSecrets = inputSecrets.map(
({ ({
filter, filter,
@@ -216,7 +230,10 @@ export const fnSecretBulkUpdate = async ({
encryptedValue, encryptedValue,
reminderRepeatDays, reminderRepeatDays,
folderId, folderId,
secretId secretId,
userActorId,
identityActorId,
actorType
}) })
), ),
tx tx
@@ -616,6 +633,12 @@ export const reshapeBridgeSecret = (
secret: Omit<TSecretsV2, "encryptedValue" | "encryptedComment"> & { secret: Omit<TSecretsV2, "encryptedValue" | "encryptedComment"> & {
value: string; value: string;
comment: string; comment: string;
userActorName?: string | null;
identityActorName?: string | null;
userActorId?: string | null;
identityActorId?: string | null;
membershipId?: string | null;
actorType?: string | null;
tags?: { tags?: {
id: string; id: string;
slug: string; slug: string;
@@ -636,6 +659,14 @@ export const reshapeBridgeSecret = (
_id: secret.id, _id: secret.id,
id: secret.id, id: secret.id,
user: secret.userId, 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, tags: secret.tags,
skipMultilineEncoding: secret.skipMultilineEncoding, skipMultilineEncoding: secret.skipMultilineEncoding,
secretReminderRepeatDays: secret.reminderRepeatDays, secretReminderRepeatDays: secret.reminderRepeatDays,
@@ -28,6 +28,7 @@ import { KmsDataKey } from "../kms/kms-types";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
import { TSecretQueueFactory } from "../secret/secret-queue"; import { TSecretQueueFactory } from "../secret/secret-queue";
import { TGetASecretByIdDTO } from "../secret/secret-types";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
@@ -73,7 +74,13 @@ type TSecretV2BridgeServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
folderDAL: Pick< folderDAL: Pick<
TSecretFolderDALFactory, TSecretFolderDALFactory,
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv" | "findBySecretPath"
| "updateById"
| "findById"
| "findByManySecretPath"
| "find"
| "findBySecretPathMultiEnv"
| "findSecretPathByFolderIds"
>; >;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">; secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">;
@@ -301,6 +308,10 @@ export const secretV2BridgeServiceFactory = ({
secretVersionDAL, secretVersionDAL,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor: {
type: actor,
actorId
},
tx tx
}) })
); );
@@ -483,6 +494,10 @@ export const secretV2BridgeServiceFactory = ({
secretVersionDAL, secretVersionDAL,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor: {
type: actor,
actorId
},
tx 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 ({ const getSecretByName = async ({
actorId, actorId,
actor, actor,
@@ -1230,6 +1312,10 @@ export const secretV2BridgeServiceFactory = ({
secretVersionDAL, secretVersionDAL,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor: {
type: actor,
actorId
},
tx tx
}) })
); );
@@ -1490,6 +1576,10 @@ export const secretV2BridgeServiceFactory = ({
secretVersionDAL, secretVersionDAL,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor: {
type: actor,
actorId
},
resourceMetadataDAL resourceMetadataDAL
}); });
updatedSecrets.push(...bulkUpdatedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); updatedSecrets.push(...bulkUpdatedSecrets.map((el) => ({ ...el, secretPath: folder.path })));
@@ -1522,6 +1612,10 @@ export const secretV2BridgeServiceFactory = ({
secretVersionDAL, secretVersionDAL,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor: {
type: actor,
actorId
},
tx tx
}); });
updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path }))); updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path })));
@@ -1689,14 +1783,19 @@ export const secretV2BridgeServiceFactory = ({
type: KmsDataKey.SecretManager, type: KmsDataKey.SecretManager,
projectId: folder.projectId projectId: folder.projectId
}); });
const secretVersions = await secretVersionDAL.find({ secretId }, { offset, limit, sort: [["createdAt", "desc"]] }); const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors(secretId, folder.projectId, {
return secretVersions.map((el) => offset,
reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, "/", { limit,
sort: [["createdAt", "desc"]]
});
return secretVersions.map((el) => {
return reshapeBridgeSecret(folder.projectId, folder.environment.envSlug, "/", {
...el, ...el,
value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "",
comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : ""
}) });
); });
}; };
// this is a backfilling API for secret references // this is a backfilling API for secret references
@@ -1956,6 +2055,10 @@ export const secretV2BridgeServiceFactory = ({
secretTagDAL, secretTagDAL,
resourceMetadataDAL, resourceMetadataDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor: {
type: actor,
actorId
},
inputSecrets: locallyCreatedSecrets.map((doc) => { inputSecrets: locallyCreatedSecrets.map((doc) => {
return { return {
type: doc.type, type: doc.type,
@@ -1982,6 +2085,10 @@ export const secretV2BridgeServiceFactory = ({
tx, tx,
secretTagDAL, secretTagDAL,
secretVersionTagDAL, secretVersionTagDAL,
actor: {
type: actor,
actorId
},
inputSecrets: locallyUpdatedSecrets.map((doc) => { inputSecrets: locallyUpdatedSecrets.map((doc) => {
return { return {
filter: { filter: {
@@ -2204,6 +2311,7 @@ export const secretV2BridgeServiceFactory = ({
getSecretsCountMultiEnv, getSecretsCountMultiEnv,
getSecretsMultiEnv, getSecretsMultiEnv,
getSecretReferenceTree, getSecretReferenceTree,
getSecretsByFolderMappings getSecretsByFolderMappings,
getSecretById
}; };
}; };
@@ -168,6 +168,10 @@ export type TFnSecretBulkInsert = {
secretVersionDAL: Pick<TSecretVersionV2DALFactory, "insertMany">; secretVersionDAL: Pick<TSecretVersionV2DALFactory, "insertMany">;
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2">; secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2">;
secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">; secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
actor?: {
type: string;
actorId: string;
};
}; };
type TRequireReferenceIfValue = type TRequireReferenceIfValue =
@@ -192,6 +196,10 @@ export type TFnSecretBulkUpdate = {
secretVersionDAL: Pick<TSecretVersionV2DALFactory, "insertMany">; secretVersionDAL: Pick<TSecretVersionV2DALFactory, "insertMany">;
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2" | "deleteTagsToSecretV2">; secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2" | "deleteTagsToSecretV2">;
secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">; secretVersionTagDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
actor?: {
type: string;
actorId: string;
};
tx?: Knex; tx?: Knex;
}; };
@@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Knex } from "knex"; import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas"; import { TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors"; 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 { logger } from "@app/lib/logger";
import { QueueName } from "@app/queue"; import { QueueName } from "@app/queue";
@@ -119,11 +120,67 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v2 completed`); logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v2 completed`);
}; };
const findVersionsBySecretIdWithActors = async (
secretId: string,
projectId: string,
{ offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt<TSecretVersionsV2> = {},
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 { return {
...secretVersionV2Orm, ...secretVersionV2Orm,
pruneExcessVersions, pruneExcessVersions,
findLatestVersionMany, findLatestVersionMany,
bulkUpdate, bulkUpdate,
findLatestVersionByFolderId findLatestVersionByFolderId,
findVersionsBySecretIdWithActors
}; };
}; };
@@ -579,6 +579,7 @@ export const fnSecretBulkInsert = async ({
[`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id [`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id
})) }))
); );
const secretVersions = await secretVersionDAL.insertMany( const secretVersions = await secretVersionDAL.insertMany(
sanitizedInputSecrets.map((el) => ({ sanitizedInputSecrets.map((el) => ({
...el, ...el,
+30 -4
View File
@@ -61,6 +61,7 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { TWebhookDALFactory } from "../webhook/webhook-dal";
import { fnTriggerWebhook } from "../webhook/webhook-fns"; import { fnTriggerWebhook } from "../webhook/webhook-fns";
import { WebhookEvents } from "../webhook/webhook-types";
import { TSecretDALFactory } from "./secret-dal"; import { TSecretDALFactory } from "./secret-dal";
import { interpolateSecrets } from "./secret-fns"; import { interpolateSecrets } from "./secret-fns";
import { import {
@@ -623,7 +624,14 @@ export const secretQueueFactory = ({
await queueService.queue( await queueService.queue(
QueueName.SecretWebhook, QueueName.SecretWebhook,
QueueJobs.SecWebhook, QueueJobs.SecWebhook,
{ environment, projectId, secretPath }, {
type: WebhookEvents.SecretModified,
payload: {
environment,
projectId,
secretPath
}
},
{ {
jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`, jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`,
removeOnFail: { count: 5 }, removeOnFail: { count: 5 },
@@ -1055,6 +1063,8 @@ export const secretQueueFactory = ({
const organization = await orgDAL.findOrgByProjectId(projectId); const organization = await orgDAL.findOrgByProjectId(projectId);
const project = await projectDAL.findById(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) { if (!organization) {
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`);
@@ -1083,6 +1093,19 @@ export const secretQueueFactory = ({
organizationName: organization.name 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) => { const startSecretV2Migration = async (projectId: string) => {
@@ -1490,14 +1513,17 @@ export const secretQueueFactory = ({
queueService.start(QueueName.SecretWebhook, async (job) => { queueService.start(QueueName.SecretWebhook, async (job) => {
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager, type: KmsDataKey.SecretManager,
projectId: job.data.projectId projectId: job.data.payload.projectId
}); });
await fnTriggerWebhook({ await fnTriggerWebhook({
...job.data, projectId: job.data.payload.projectId,
environment: job.data.payload.environment,
secretPath: job.data.payload.secretPath || "/",
projectEnvDAL, projectEnvDAL,
webhookDAL,
projectDAL, projectDAL,
webhookDAL,
event: job.data,
secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString() secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString()
}); });
}); });
+16 -2
View File
@@ -71,6 +71,7 @@ import {
TDeleteManySecretRawDTO, TDeleteManySecretRawDTO,
TDeleteSecretDTO, TDeleteSecretDTO,
TDeleteSecretRawDTO, TDeleteSecretRawDTO,
TGetASecretByIdRawDTO,
TGetASecretDTO, TGetASecretDTO,
TGetASecretRawDTO, TGetASecretRawDTO,
TGetSecretAccessListDTO, TGetSecretAccessListDTO,
@@ -95,7 +96,7 @@ type TSecretServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
folderDAL: Pick< folderDAL: Pick<
TSecretFolderDALFactory, TSecretFolderDALFactory,
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findSecretPathByFolderIds"
>; >;
secretV2BridgeService: TSecretV2BridgeServiceFactory; secretV2BridgeService: TSecretV2BridgeServiceFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory; 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 ({ const getSecretByNameRaw = async ({
type, type,
path, path,
@@ -3088,6 +3101,7 @@ export const secretServiceFactory = ({
getSecretsRawMultiEnv, getSecretsRawMultiEnv,
getSecretReferenceTree, getSecretReferenceTree,
getSecretsRawByFolderMappings, getSecretsRawByFolderMappings,
getSecretAccessList getSecretAccessList,
getSecretByIdRaw
}; };
}; };
@@ -121,6 +121,10 @@ export type TGetASecretDTO = {
version?: number; version?: number;
} & TProjectPermission; } & TProjectPermission;
export type TGetASecretByIdDTO = {
secretId: string;
} & Omit<TProjectPermission, "projectId">;
export type TCreateBulkSecretDTO = { export type TCreateBulkSecretDTO = {
path: string; path: string;
environment: string; environment: string;
@@ -213,6 +217,10 @@ export type TGetASecretRawDTO = {
projectId?: string; projectId?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetASecretByIdRawDTO = {
secretId: string;
} & Omit<TProjectPermission, "projectId">;
export type TCreateSecretRawDTO = TProjectPermission & { export type TCreateSecretRawDTO = TProjectPermission & {
secretName: string; secretName: string;
secretPath: string; secretPath: string;
+1
View File
@@ -50,6 +50,7 @@ const buildSlackPayload = (notification: TSlackNotification) => {
const messageBody = `A secret approval request has been opened by ${payload.userEmail}. const messageBody = `A secret approval request has been opened by ${payload.userEmail}.
*Environment*: ${payload.environment} *Environment*: ${payload.environment}
*Secret path*: ${payload.secretPath || "/"} *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=${ View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId}/approval?requestId=${
payload.requestId payload.requestId
@@ -62,6 +62,7 @@ export type TSlackNotification =
secretPath: string; secretPath: string;
requestId: string; requestId: string;
projectId: string; projectId: string;
secretKeys: string[];
}; };
} }
| { | {
+81 -32
View File
@@ -11,7 +11,7 @@ import { logger } from "@app/lib/logger";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TWebhookDALFactory } from "./webhook-dal"; import { TWebhookDALFactory } from "./webhook-dal";
import { WebhookType } from "./webhook-types"; import { TWebhookPayloads, WebhookEvents, WebhookType } from "./webhook-types";
const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000; const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000;
@@ -54,29 +54,64 @@ export const triggerWebhookRequest = async (
return req; return req;
}; };
export const getWebhookPayload = ( export const getWebhookPayload = (event: TWebhookPayloads) => {
eventName: string, if (event.type === WebhookEvents.SecretModified) {
details: { const { projectName, projectId, environment, secretPath, type } = event.payload;
workspaceName: string;
workspaceId: string; switch (type) {
environment: string; case WebhookType.SLACK:
secretPath?: string; return {
type?: string | null; 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) { switch (type) {
case WebhookType.SLACK: case WebhookType.SLACK:
return { return {
text: "A secret value has been added or modified.", text: "You have a secret reminder",
attachments: [ attachments: [
{ {
color: "#E7F256", color: "#E7F256",
fields: [ fields: [
{ {
title: "Project", title: "Project",
value: workspaceName, value: projectName,
short: false short: false
}, },
{ {
@@ -88,6 +123,16 @@ export const getWebhookPayload = (
title: "Secret Path", title: "Secret Path",
value: secretPath, value: secretPath,
short: false 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: case WebhookType.GENERAL:
default: default:
return { return {
event: eventName, event: event.type,
project: { project: {
workspaceId, workspaceId: projectId,
projectName,
environment, environment,
secretPath secretPath,
secretName,
reminderNote
} }
}; };
} }
@@ -110,6 +158,7 @@ export type TFnTriggerWebhookDTO = {
projectId: string; projectId: string;
secretPath: string; secretPath: string;
environment: string; environment: string;
event: TWebhookPayloads;
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">; webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
@@ -124,8 +173,9 @@ export const fnTriggerWebhook = async ({
projectId, projectId,
webhookDAL, webhookDAL,
projectEnvDAL, projectEnvDAL,
projectDAL, event,
secretManagerDecryptor secretManagerDecryptor,
projectDAL
}: TFnTriggerWebhookDTO) => { }: TFnTriggerWebhookDTO) => {
const webhooks = await webhookDAL.findAllWebhooks(projectId, environment); const webhooks = await webhookDAL.findAllWebhooks(projectId, environment);
const toBeTriggeredHooks = webhooks.filter( const toBeTriggeredHooks = webhooks.filter(
@@ -134,21 +184,20 @@ export const fnTriggerWebhook = async ({
); );
if (!toBeTriggeredHooks.length) return; if (!toBeTriggeredHooks.length) return;
logger.info({ environment, secretPath, projectId }, "Secret webhook job started"); 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( const webhooksTriggered = await Promise.allSettled(
toBeTriggeredHooks.map((hook) => toBeTriggeredHooks.map((hook) => {
triggerWebhookRequest( const formattedEvent = {
hook, type: event.type,
secretManagerDecryptor, payload: { ...event.payload, type: hook.type, projectName }
getWebhookPayload("secrets.modified", { } as TWebhookPayloads;
workspaceName: project.name, return triggerWebhookRequest(hook, secretManagerDecryptor, getWebhookPayload(formattedEvent));
workspaceId: projectId, })
environment,
secretPath,
type: hook.type
})
)
)
); );
// filter hooks by status // filter hooks by status
@@ -16,7 +16,8 @@ import {
TDeleteWebhookDTO, TDeleteWebhookDTO,
TListWebhookDTO, TListWebhookDTO,
TTestWebhookDTO, TTestWebhookDTO,
TUpdateWebhookDTO TUpdateWebhookDTO,
WebhookEvents
} from "./webhook-types"; } from "./webhook-types";
type TWebhookServiceFactoryDep = { type TWebhookServiceFactoryDep = {
@@ -144,12 +145,15 @@ export const webhookServiceFactory = ({
await triggerWebhookRequest( await triggerWebhookRequest(
webhook, webhook,
(value) => secretManagerDecryptor({ cipherTextBlob: value }).toString(), (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString(),
getWebhookPayload("test", { getWebhookPayload({
workspaceName: project.name, type: "test" as WebhookEvents.SecretModified,
workspaceId: webhook.projectId, payload: {
environment: webhook.environment.slug, projectName: project.name,
secretPath: webhook.secretPath, projectId: webhook.projectId,
type: webhook.type environment: webhook.environment.slug,
secretPath: webhook.secretPath,
type: webhook.type
}
}) })
); );
} catch (err) { } catch (err) {
@@ -30,3 +30,36 @@ export enum WebhookType {
GENERAL = "general", GENERAL = "general",
SLACK = "slack" 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;
+8
View File
@@ -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
+5
View File
@@ -1,3 +1,8 @@
public_ip: 127.0.0.1 public_ip: 127.0.0.1
auth_secret: changeThisOnProduction auth_secret: changeThisOnProduction
realm: infisical.org 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
+4 -2
View File
@@ -28,8 +28,9 @@ require (
github.com/rs/zerolog v1.26.1 github.com/rs/zerolog v1.26.1
github.com/spf13/cobra v1.6.1 github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.8.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/crypto v0.35.0
golang.org/x/sys v0.30.0
golang.org/x/term v0.29.0 golang.org/x/term v0.29.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
@@ -115,7 +116,6 @@ require (
golang.org/x/net v0.35.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.11.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/text v0.22.0 // indirect
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.30.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/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
+4 -4
View File
@@ -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/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 h1:dWOkI/pB/7RocfSJgGXbXxLDcVYsdslgjEPmVhb+nl8=
github.com/Infisical/go-keyring v1.0.2/go.mod h1:LWOnn/sw9FxDW/0VY+jHFAfOFEe03xmwBVSfJnBowto= 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 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= 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= 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/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 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= 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.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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.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.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.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
-5
View File
@@ -137,15 +137,10 @@ var gatewayRelayCmd = &cobra.Command{
} }
func init() { 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") gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token")
gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path") gatewayRelayCmd.Flags().String("config", "", "Relay config yaml file path")
gatewayCmd.AddCommand(gatewayRelayCmd) gatewayCmd.AddCommand(gatewayRelayCmd)
rootCmd.AddCommand(gatewayCmd) rootCmd.AddCommand(gatewayCmd)
} }
+2 -1
View File
@@ -61,7 +61,6 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) {
switch string(cmd) { switch string(cmd) {
case "FORWARD-TCP": case "FORWARD-TCP":
log.Info().Msg("Starting secure connector proxy...")
proxyAddress := string(bytes.Split(args, []byte(" "))[0]) proxyAddress := string(bytes.Split(args, []byte(" "))[0])
destTarget, err := net.Dial("tcp", proxyAddress) destTarget, err := net.Dial("tcp", proxyAddress)
if err != nil { if err != nil {
@@ -69,6 +68,7 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) {
return return
} }
defer destTarget.Close() defer destTarget.Close()
log.Info().Msgf("Starting secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String())
// Handle buffered data // Handle buffered data
buffered := reader.Buffered() buffered := reader.Buffered()
@@ -87,6 +87,7 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) {
} }
CopyDataFromQuicToTcp(stream, destTarget) CopyDataFromQuicToTcp(stream, destTarget)
log.Info().Msgf("Ending secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String())
return return
case "PING": case "PING":
if _, err := stream.Write([]byte("PONG\n")); err != nil { if _, err := stream.Write([]byte("PONG\n")); err != nil {
+81 -79
View File
@@ -6,11 +6,13 @@ import (
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"net" "net"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/api"
"github.com/Infisical/infisical-merge/packages/systemd"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"github.com/pion/logging" "github.com/pion/logging"
"github.com/pion/turn/v4" "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 // 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 // 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{ cfg := &turn.ClientConfig{
STUNServerAddr: relayDetails.TurnServerAddress, STUNServerAddr: relayDetails.TurnServerAddress,
TURNServerAddr: relayDetails.TurnServerAddress, TURNServerAddr: relayDetails.TurnServerAddress,
@@ -82,7 +88,7 @@ func (g *Gateway) ConnectWithRelay() error {
Username: relayDetails.TurnServerUsername, Username: relayDetails.TurnServerUsername,
Password: relayDetails.TurnServerPassword, Password: relayDetails.TurnServerPassword,
Realm: relayDetails.TurnServerRealm, Realm: relayDetails.TurnServerRealm,
LoggerFactory: logging.NewDefaultLoggerFactory(), LoggerFactory: logger,
} }
client, err := turn.NewClient(cfg) client, err := turn.NewClient(cfg)
@@ -96,10 +102,6 @@ func (g *Gateway) ConnectWithRelay() error {
TurnServerAddress: relayDetails.TurnServerAddress, TurnServerAddress: relayDetails.TurnServerAddress,
InfisicalStaticIp: relayDetails.InfisicalStaticIp, 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 g.client = client
return nil return nil
@@ -144,7 +146,10 @@ func (g *Gateway) Listen(ctx context.Context) error {
errCh := make(chan error, 1) errCh := make(chan error, 1)
shutdownCh := make(chan bool, 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) g.registerHeartBeat(ctx, errCh)
cert, err := tls.X509KeyPair([]byte(gatewayCert.Certificate), []byte(gatewayCert.PrivateKey)) 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, KeepAlivePeriod: 2 * time.Second,
} }
g.registerRelayIsActive(ctx, relayUdpConnection.LocalAddr().String(), tlsConfig, quicConfig, errCh) g.registerRelayIsActive(ctx, errCh)
quicListener, err := quic.Listen(relayUdpConnection, tlsConfig, quicConfig) quicListener, err := quic.Listen(relayUdpConnection, tlsConfig, quicConfig)
if err != nil { if err != nil {
return fmt.Errorf("Failed to listen for QUIC: %w", err) 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 { select {
case <-ctx.Done(): case <-ctx.Done():
log.Info().Msg("Shutting down gateway...") 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) { func (g *Gateway) createPermissionForStaticIps(staticIps string) error {
ticker := time.NewTicker(5 * time.Second) 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 maxFailures := 3
failures := 0 failures := 0
log.Info().Msg("Starting relay connection health check")
go func() { go func() {
time.Sleep(2 * time.Second) time.Sleep(5 * time.Second)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Info().Msg("Stopping relay connection health check")
return return
case <-ticker.C: case <-ticker.C:
conn, err := quic.DialAddr(ctx, serverAddr, tlsConf, quicConf) func() {
if conn != nil { log.Debug().Msg("Performing relay connection health check")
failures = 0
conn.CloseWithError(0, "connection closed")
}
if err != nil && !strings.Contains(err.Error(), "tls: failed to verify certificate") { if g.client == nil {
failures++ failures++
log.Warn().Err(err).Int("failures", failures).Msg("Relay connection check failed") log.Warn().Int("failures", failures).Msg("TURN client is nil")
if failures >= maxFailures {
if failures >= maxFailures { errCh <- fmt.Errorf("relay connection check failed: TURN client is nil")
errCh <- fmt.Errorf("relay connection check failed: %w", err) }
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) { return nil
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)
}
}
} }
+23 -9
View File
@@ -1,8 +1,12 @@
//go:build !windows
// +build !windows
package gateway package gateway
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@@ -13,6 +17,7 @@ import (
"syscall" "syscall"
udplistener "github.com/Infisical/infisical-merge/packages/gateway/udp_listener" udplistener "github.com/Infisical/infisical-merge/packages/gateway/udp_listener"
"github.com/Infisical/infisical-merge/packages/systemd"
"github.com/pion/logging" "github.com/pion/logging"
"github.com/pion/turn/v4" "github.com/pion/turn/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -36,8 +41,10 @@ type GatewayRelayConfig struct {
RelayMaxPort uint16 `yaml:"relay_max_port"` RelayMaxPort uint16 `yaml:"relay_max_port"`
TlsCertPath string `yaml:"tls_cert_path"` TlsCertPath string `yaml:"tls_cert_path"`
TlsPrivateKeyPath string `yaml:"tls_private_key_path"` TlsPrivateKeyPath string `yaml:"tls_private_key_path"`
TlsCaPath string `yaml:"tls_ca_path"`
tls tls.Certificate tls tls.Certificate
tlsCa string
isTlsEnabled bool isTlsEnabled bool
} }
@@ -78,19 +85,19 @@ func NewGatewayRelay(configFilePath string) (*GatewayRelay, error) {
return nil, errMissingTlsCert return nil, errMissingTlsCert
} }
tlsCertFile, err := os.ReadFile(cfg.TlsCertPath) cert, err := tls.LoadX509KeyPair(cfg.TlsCertPath, cfg.TlsPrivateKeyPath)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("Failed to read load server tls key pair: %w", err)
}
tlsPrivateKeyFile, err := os.ReadFile(cfg.TlsPrivateKeyPath)
if err != nil {
return nil, err
} }
cert, err := tls.LoadX509KeyPair(string(tlsCertFile), string(tlsPrivateKeyFile)) if cfg.TlsCaPath != "" {
if err != nil { ca, err := os.ReadFile(cfg.TlsCaPath)
return nil, err if err != nil {
return nil, fmt.Errorf("Failed to read tls ca: %w", err)
}
cfg.tlsCa = string(ca)
} }
cfg.tls = cert cfg.tls = cert
cfg.isTlsEnabled = true cfg.isTlsEnabled = true
} }
@@ -139,8 +146,12 @@ func (g *GatewayRelay) Run() error {
} }
if g.Config.isTlsEnabled { if g.Config.isTlsEnabled {
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa))
listenerConfigs[i].Listener = tls.NewListener(conn, &tls.Config{ listenerConfigs[i].Listener = tls.NewListener(conn, &tls.Config{
Certificates: []tls.Certificate{g.Config.tls}, Certificates: []tls.Certificate{g.Config.tls},
ClientCAs: caCertPool,
}) })
} else { } else {
listenerConfigs[i].Listener = conn listenerConfigs[i].Listener = conn
@@ -164,6 +175,9 @@ func (g *GatewayRelay) Run() error {
} }
log.Info().Msgf("Relay listening on %s\n", connAddress) 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 // Block until user sends SIGINT or SIGTERM
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
+37
View File
@@ -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
}
+84
View File
@@ -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
}
+15
View File
@@ -36,3 +36,18 @@ If the signature in the header matches the signature that you generated, then yo
"timestamp": "" "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": ""
}
```
@@ -122,6 +122,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
"OIDC group membership mapping assigned user to groups", "OIDC group membership mapping assigned user to groups",
[EventType.OIDC_GROUP_MEMBERSHIP_MAPPING_REMOVE_USER]: [EventType.OIDC_GROUP_MEMBERSHIP_MAPPING_REMOVE_USER]:
"OIDC group membership mapping removed user from groups", "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.CREATE_KMIP_CLIENT]: "Create KMIP client",
[EventType.UPDATE_KMIP_CLIENT]: "Update KMIP client", [EventType.UPDATE_KMIP_CLIENT]: "Update KMIP client",
[EventType.DELETE_KMIP_CLIENT]: "Delete KMIP client", [EventType.DELETE_KMIP_CLIENT]: "Delete KMIP client",
+2 -1
View File
@@ -150,5 +150,6 @@ export enum EventType {
KMIP_OPERATION_ACTIVATE = "kmip-operation-activate", KMIP_OPERATION_ACTIVATE = "kmip-operation-activate",
KMIP_OPERATION_REVOKE = "kmip-operation-revoke", KMIP_OPERATION_REVOKE = "kmip-operation-revoke",
KMIP_OPERATION_LOCATE = "kmip-operation-locate", 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"
} }
@@ -13,9 +13,10 @@ export const useUpdateSecretApprovalReviewStatus = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<object, object, TUpdateSecretApprovalReviewStatusDTO>({ return useMutation<object, object, TUpdateSecretApprovalReviewStatusDTO>({
mutationFn: async ({ id, status }) => { mutationFn: async ({ id, status, comment }) => {
const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/review`, { const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/review`, {
status status,
comment
}); });
return data; return data;
}, },
@@ -44,6 +44,7 @@ export type TSecretApprovalRequest = {
reviewers: { reviewers: {
userId: string; userId: string;
status: ApprovalStatus; status: ApprovalStatus;
comment: string;
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
@@ -114,6 +115,7 @@ export type TGetSecretApprovalRequestDetails = {
export type TUpdateSecretApprovalReviewStatusDTO = { export type TUpdateSecretApprovalReviewStatusDTO = {
status: ApprovalStatus; status: ApprovalStatus;
comment?: string;
id: string; id: string;
}; };
+6
View File
@@ -101,6 +101,12 @@ export type SecretVersions = {
skipMultilineEncoding?: boolean; skipMultilineEncoding?: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
actor?: {
actorId?: string | null;
actorType?: string | null;
name?: string | null;
membershipId?: string | null;
} | null;
}; };
// dto // dto
@@ -1,19 +1,36 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Controller, useForm } from "react-hook-form";
import { import {
faAngleDown,
faArrowLeft, faArrowLeft,
faCheck,
faCheckCircle, faCheckCircle,
faCircle, faCircle,
faCodeBranch, faCodeBranch,
faComment,
faFolder, faFolder,
faXmarkCircle faXmarkCircle
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { twMerge } from "tailwind-merge";
import z from "zod";
import { createNotification } from "@app/components/notifications"; 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 { useUser } from "@app/context";
import { usePopUp } from "@app/hooks";
import { import {
useGetSecretApprovalRequestDetails, useGetSecretApprovalRequestDetails,
useUpdateSecretApprovalReviewStatus useUpdateSecretApprovalReviewStatus
@@ -74,6 +91,13 @@ type Props = {
onGoBack: () => void; onGoBack: () => void;
}; };
const reviewFormSchema = z.object({
comment: z.string().trim().optional().default(""),
status: z.nativeEnum(ApprovalStatus)
});
type TReviewFormSchema = z.infer<typeof reviewFormSchema>;
export const SecretApprovalRequestChanges = ({ export const SecretApprovalRequestChanges = ({
approvalRequestId, approvalRequestId,
onGoBack, onGoBack,
@@ -94,6 +118,16 @@ export const SecretApprovalRequestChanges = ({
variables variables
} = useUpdateSecretApprovalReviewStatus(); } = useUpdateSecretApprovalReviewStatus();
const { popUp, handlePopUpToggle } = usePopUp(["reviewChanges"] as const);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TReviewFormSchema>({
resolver: zodResolver(reviewFormSchema)
});
const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus; const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus;
const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus; const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus;
@@ -101,23 +135,23 @@ export const SecretApprovalRequestChanges = ({
const canApprove = secretApprovalRequestDetails?.policy?.approvers?.some( const canApprove = secretApprovalRequestDetails?.policy?.approvers?.some(
({ userId }) => userId === userSession.id ({ userId }) => userId === userSession.id
); );
const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce< const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce<
Record<string, ApprovalStatus> Record<string, { status: ApprovalStatus; comment: string }>
>( >(
(prev, curr) => ({ (prev, curr) => ({
...prev, ...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 { try {
await updateSecretApprovalRequestStatus({ await updateSecretApprovalRequestStatus({
id: approvalRequestId, id: approvalRequestId,
status status,
comment
}); });
createNotification({ createNotification({
type: "success", type: "success",
@@ -130,6 +164,16 @@ export const SecretApprovalRequestChanges = ({
text: "Failed to update the request status" 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) { if (isSecretApprovalRequestLoading) {
@@ -150,7 +194,7 @@ export const SecretApprovalRequestChanges = ({
const isMergable = const isMergable =
secretApprovalRequestDetails?.policy?.approvals <= secretApprovalRequestDetails?.policy?.approvals <=
secretApprovalRequestDetails?.policy?.approvers?.filter( secretApprovalRequestDetails?.policy?.approvers?.filter(
({ userId }) => reviewedUsers?.[userId] === ApprovalStatus.APPROVED ({ userId }) => reviewedUsers?.[userId]?.status === ApprovalStatus.APPROVED
).length; ).length;
const hasMerged = secretApprovalRequestDetails?.hasMerged; const hasMerged = secretApprovalRequestDetails?.hasMerged;
@@ -202,27 +246,115 @@ export const SecretApprovalRequestChanges = ({
</div> </div>
</div> </div>
{!hasMerged && secretApprovalRequestDetails.status === "open" && ( {!hasMerged && secretApprovalRequestDetails.status === "open" && (
<> <DropdownMenu
<Button open={popUp.reviewChanges.isOpen}
size="xs" onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)}
leftIcon={hasApproved && <FontAwesomeIcon icon={faCheck} />} >
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.APPROVED)} <DropdownMenuTrigger asChild>
isLoading={isApproving} <Button
isDisabled={isApproving || hasApproved || !canApprove} variant="outline_bg"
> rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
{hasApproved ? "Approved" : "Approve"} >
</Button> Review
<Button </Button>
size="xs" </DropdownMenuTrigger>
colorSchema="danger" <DropdownMenuContent align="end" asChild className="mt-3">
leftIcon={hasRejected && <FontAwesomeIcon icon={faCheck} />} <form onSubmit={handleSubmit(handleSubmitReview)}>
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.REJECTED)} <div className="flex w-[400px] flex-col space-y-2 p-5">
isLoading={isRejecting} <div className="text-lg font-medium">Finish your review</div>
isDisabled={isRejecting || hasRejected || !canApprove} <Controller
> control={control}
{hasRejected ? "Rejected" : "Reject"} name="comment"
</Button> render={({ field, fieldState: { error } }) => (
</> <FormControl errorText={error?.message} isError={Boolean(error)}>
<TextArea
{...field}
placeholder="Leave a comment..."
reSize="none"
className="text-md mt-2 h-48 border border-mineshaft-600 bg-bunker-800"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="status"
defaultValue={ApprovalStatus.APPROVED}
render={({ field, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<RadioGroup
value={field.value}
onValueChange={field.onChange}
className="mb-4 space-y-2"
aria-label="Status"
>
<div className="flex items-center gap-2">
<RadioGroupItem
id="approve"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.APPROVED}
aria-labelledby="approve-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="approve-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.APPROVED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.APPROVED);
}
}}
tabIndex={0}
role="button"
>
Approve
</span>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
id="reject"
className="h-4 w-4 rounded-full border border-gray-300 text-primary focus:ring-2 focus:ring-mineshaft-500"
value={ApprovalStatus.REJECTED}
aria-labelledby="reject-label"
>
<RadioGroupIndicator className="flex h-full w-full items-center justify-center after:h-2 after:w-2 after:rounded-full after:bg-current" />
</RadioGroupItem>
<span
id="reject-label"
className="cursor-pointer"
onClick={() => field.onChange(ApprovalStatus.REJECTED)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
field.onChange(ApprovalStatus.REJECTED);
}
}}
tabIndex={0}
role="button"
>
Reject
</span>
</div>
</RadioGroup>
</FormControl>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
isLoading={isApproving || isRejecting || isSubmitting}
variant="outline_bg"
>
Submit Review
</Button>
</div>
</div>
</form>
</DropdownMenuContent>
</DropdownMenu>
)} )}
</div> </div>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
@@ -240,7 +372,40 @@ export const SecretApprovalRequestChanges = ({
) )
)} )}
</div> </div>
<div className="mt-8 flex items-center space-x-6 rounded-lg bg-mineshaft-800 px-5 py-6"> <div className="mt-4 flex flex-col items-center rounded-lg">
{secretApprovalRequestDetails?.policy?.approvers
.filter((requiredApprover) => reviewedUsers?.[requiredApprover.userId])
.map((requiredApprover) => {
const reviewer = reviewedUsers?.[requiredApprover.userId];
return (
<div
className="mb-4 flex w-full flex-col rounded-md bg-mineshaft-800 p-6"
key={`required-approver-${requiredApprover.userId}`}
>
<div>
<span className="ml-1">
{`${requiredApprover.firstName || ""} ${requiredApprover.lastName || ""}`} (
{requiredApprover?.email}) has{" "}
</span>
<span
className={`${reviewer?.status === ApprovalStatus.APPROVED ? "text-green-500" : "text-red-500"}`}
>
{reviewer?.status === ApprovalStatus.APPROVED ? "approved" : "rejected"}
</span>{" "}
the request.
</div>
{reviewer?.comment && (
<FormControl label="Comment" className="mb-0 mt-2">
<TextArea value={reviewer.comment} isDisabled reSize="none">
{reviewer?.comment && reviewer.comment}
</TextArea>
</FormControl>
)}
</div>
);
})}
</div>
<div className="flex items-center space-x-6 rounded-lg bg-mineshaft-800 px-5 py-6">
<SecretApprovalRequestAction <SecretApprovalRequestAction
canApprove={canApprove} canApprove={canApprove}
approvalRequestId={secretApprovalRequestDetails.id} approvalRequestId={secretApprovalRequestDetails.id}
@@ -258,7 +423,7 @@ export const SecretApprovalRequestChanges = ({
<div className="text-sm text-bunker-300">Reviewers</div> <div className="text-sm text-bunker-300">Reviewers</div>
<div className="mt-2 flex flex-col space-y-2 text-sm"> <div className="mt-2 flex flex-col space-y-2 text-sm">
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => { {secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => {
const status = reviewedUsers?.[requiredApprover.userId]; const reviewer = reviewedUsers?.[requiredApprover.userId];
return ( return (
<div <div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1" className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
@@ -275,8 +440,17 @@ export const SecretApprovalRequestChanges = ({
<span className="text-red">*</span> <span className="text-red">*</span>
</div> </div>
<div> <div>
<Tooltip content={status || ApprovalStatus.PENDING}> {reviewer?.comment && (
{getReviewedStatusSymbol(status)} <Tooltip content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
className="mr-1 text-mineshaft-300"
/>
</Tooltip>
)}
<Tooltip content={reviewer?.status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(reviewer?.status)}
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@@ -290,7 +464,7 @@ export const SecretApprovalRequestChanges = ({
) )
) )
.map((reviewer) => { .map((reviewer) => {
const status = reviewedUsers?.[reviewer.userId]; const status = reviewedUsers?.[reviewer.userId].status;
return ( return (
<div <div
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1" className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
@@ -303,6 +477,15 @@ export const SecretApprovalRequestChanges = ({
<span className="text-red">*</span> <span className="text-red">*</span>
</div> </div>
<div> <div>
{reviewer.comment && (
<Tooltip content={reviewer.comment}>
<FontAwesomeIcon
icon={faComment}
size="xs"
className="mr-1 text-mineshaft-300"
/>
</Tooltip>
)}
<Tooltip content={status || ApprovalStatus.PENDING}> <Tooltip content={status || ApprovalStatus.PENDING}>
{getReviewedStatusSymbol(status)} {getReviewedStatusSymbol(status)}
</Tooltip> </Tooltip>
@@ -5,15 +5,19 @@ import {
faArrowRotateRight, faArrowRotateRight,
faCheckCircle, faCheckCircle,
faClock, faClock,
faCopy,
faDesktop,
faEyeSlash, faEyeSlash,
faPlus, faPlus,
faServer,
faShare, faShare,
faTag, faTag,
faTrash faTrash,
faUser
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { format } from "date-fns";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -46,6 +50,7 @@ import {
} from "@app/context"; } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { useGetSecretVersion } from "@app/hooks/api"; import { useGetSecretVersion } from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries"; import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectType } from "@app/hooks/api/workspace/types";
@@ -120,6 +125,7 @@ export const SecretDetailSidebar = ({
{} {}
); );
const selectTagSlugs = selectedTags.map((i) => i.slug); const selectTagSlugs = selectedTags.map((i) => i.slug);
const navigate = useNavigate();
const cannotEditSecret = permission.cannot( const cannotEditSecret = permission.cannot(
ProjectPermissionActions.Edit, ProjectPermissionActions.Edit,
@@ -192,15 +198,73 @@ export const SecretDetailSidebar = ({
await onSaveSecret(secret, { ...secret, ...data }, () => reset()); await onSaveSecret(secret, { ...secret, ...data }, () => reset());
}; };
const handleReminderSubmit = async (reminderRepeatDays: number | null | undefined, reminderNote: string | null | undefined) => { const handleReminderSubmit = async (
await onSaveSecret(secret, { ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true }, () => { }); reminderRepeatDays: number | null | undefined,
} reminderNote: string | null | undefined
) => {
await onSaveSecret(
secret,
{ ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true },
() => {}
);
};
const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false); const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false);
const secretReminderRepeatDays = watch("reminderRepeatDays"); const secretReminderRepeatDays = watch("reminderRepeatDays");
const secretReminderNote = watch("reminderNote"); 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 ( return (
<> <>
<CreateReminderForm <CreateReminderForm
@@ -213,7 +277,7 @@ export const SecretDetailSidebar = ({
if (data) { if (data) {
setValue("reminderRepeatDays", data.days, { shouldDirty: false }); setValue("reminderRepeatDays", data.days, { shouldDirty: false });
setValue("reminderNote", data.note, { shouldDirty: false }); setValue("reminderNote", data.note, { shouldDirty: false });
handleReminderSubmit(data.days, data.note) handleReminderSubmit(data.days, data.note);
} }
}} }}
/> />
@@ -618,7 +682,7 @@ export const SecretDetailSidebar = ({
<div className="mb-4flex-grow dark cursor-default text-sm text-bunker-300"> <div className="mb-4flex-grow dark cursor-default text-sm text-bunker-300">
<div className="mb-2 pl-1">Version History</div> <div className="mb-2 pl-1">Version History</div>
<div className="thin-scrollbar flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4 dark:[color-scheme:dark]"> <div className="thin-scrollbar flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4 dark:[color-scheme:dark]">
{secretVersion?.map(({ createdAt, secretValue, version, id }) => ( {secretVersion?.map(({ createdAt, secretValue, version, id, actor }) => (
<div className="flex flex-row"> <div className="flex flex-row">
<div key={id} className="flex w-full flex-col space-y-1"> <div key={id} className="flex w-full flex-col space-y-1">
<div className="flex items-center"> <div className="flex items-center">
@@ -633,36 +697,42 @@ export const SecretDetailSidebar = ({
<div className="relative w-10"> <div className="relative w-10">
<div className="absolute bottom-0 left-3 top-0 mt-0.5 border-l border-mineshaft-400/60" /> <div className="absolute bottom-0 left-3 top-0 mt-0.5 border-l border-mineshaft-400/60" />
</div> </div>
<div className="flex flex-row"> <div className="flex w-full cursor-default flex-col">
<div className="h-min w-fit rounded-sm bg-primary-500/10 px-1 text-primary-300/70"> {actor && (
Value: <div className="flex flex-row">
</div> <div className="flex w-fit flex-row text-sm">
<div className="group break-all pl-1 font-mono"> Modified by:
<div className="relative hidden cursor-pointer transition-all duration-200 group-[.show-value]:inline"> <Tooltip content={getModifiedByName(actor.actorType, actor.name)}>
<button {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
type="button" <div
className="select-none" onClick={() =>
onClick={(e) => { onModifyHistoryClick(
navigator.clipboard.writeText(secretValue || ""); actor.actorId,
const target = e.currentTarget; actor.actorType,
target.style.borderBottom = "1px dashed"; actor.membershipId
target.style.paddingBottom = "-1px"; )
}
// Create and insert popup className="cursor-pointer"
const popup = document.createElement("div"); >
popup.className = <FontAwesomeIcon
"w-16 flex justify-center absolute top-6 left-0 text-xs text-primary-100 bg-mineshaft-800 px-1 py-0.5 rounded-md border border-primary-500/50"; icon={getModifiedByIcon(actor.actorType)}
popup.textContent = "Copied!"; className="ml-2"
target.parentElement?.appendChild(popup); />
</div>
// Remove popup and border after delay </Tooltip>
setTimeout(() => { </div>
popup.remove(); </div>
target.style.borderBottom = "none"; )}
}, 3000); <div className="flex flex-row">
}} <div className="h-min w-fit rounded-sm bg-primary-500/10 px-1 text-primary-300/70">
onKeyDown={(e) => { Value:
if (e.key === "Enter" || e.key === " ") { </div>
<div className="group break-all pl-1 font-mono">
<div className="relative hidden cursor-pointer transition-all duration-200 group-[.show-value]:inline">
<button
type="button"
className="select-none text-left"
onClick={(e) => {
navigator.clipboard.writeText(secretValue || ""); navigator.clipboard.writeText(secretValue || "");
const target = e.currentTarget; const target = e.currentTarget;
target.style.borderBottom = "1px dashed"; target.style.borderBottom = "1px dashed";
@@ -680,51 +750,74 @@ export const SecretDetailSidebar = ({
popup.remove(); popup.remove();
target.style.borderBottom = "none"; target.style.borderBottom = "none";
}, 3000); }, 3000);
} }}
}} onKeyDown={(e) => {
> if (e.key === "Enter" || e.key === " ") {
{secretValue} navigator.clipboard.writeText(secretValue || "");
</button> const target = e.currentTarget;
<button target.style.borderBottom = "1px dashed";
type="button" target.style.paddingBottom = "-1px";
className="ml-1 cursor-pointer"
onClick={(e) => { // Create and insert popup
e.stopPropagation(); const popup = document.createElement("div");
e.currentTarget popup.className =
.closest(".group") "w-16 flex justify-center absolute top-6 left-0 text-xs text-primary-100 bg-mineshaft-800 px-1 py-0.5 rounded-md border border-primary-500/50";
?.classList.remove("show-value"); popup.textContent = "Copied!";
}} target.parentElement?.appendChild(popup);
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { // Remove popup and border after delay
setTimeout(() => {
popup.remove();
target.style.borderBottom = "none";
}, 3000);
}
}}
>
{secretValue}
</button>
<button
type="button"
className="ml-1 cursor-pointer"
onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.currentTarget e.currentTarget
.closest(".group") .closest(".group")
?.classList.remove("show-value"); ?.classList.remove("show-value");
} }}
}} onKeyDown={(e) => {
> if (e.key === "Enter" || e.key === " ") {
<FontAwesomeIcon icon={faEyeSlash} /> e.stopPropagation();
</button> e.currentTarget
</div> .closest(".group")
<span className="group-[.show-value]:hidden"> ?.classList.remove("show-value");
{secretValue?.replace(/./g, "*")} }
<button }}
type="button" >
className="ml-1 cursor-pointer" <FontAwesomeIcon icon={faEyeSlash} />
onClick={(e) => { </button>
e.currentTarget.closest(".group")?.classList.add("show-value"); </div>
}} <span className="group-[.show-value]:hidden">
onKeyDown={(e) => { {secretValue?.replace(/./g, "*")}
if (e.key === "Enter" || e.key === " ") { <button
type="button"
className="ml-1 cursor-pointer"
onClick={(e) => {
e.currentTarget e.currentTarget
.closest(".group") .closest(".group")
?.classList.add("show-value"); ?.classList.add("show-value");
} }}
}} onKeyDown={(e) => {
> if (e.key === "Enter" || e.key === " ") {
<FontAwesomeIcon icon={faEye} /> e.currentTarget
</button> .closest(".group")
</span> ?.classList.add("show-value");
}
}}
>
<FontAwesomeIcon icon={faEye} />
</button>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -898,29 +991,49 @@ export const SecretDetailSidebar = ({
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <div className="flex items-center gap-2">
I={ProjectPermissionActions.Delete} <Tooltip content="Copy Secret ID">
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<IconButton <IconButton
colorSchema="danger" variant="outline_bg"
ariaLabel="Delete Secret" ariaLabel="Copy Secret ID"
className="border border-mineshaft-600 bg-mineshaft-700 hover:border-red-500/70 hover:bg-red-600/20" onClick={async () => {
isDisabled={!isAllowed} await navigator.clipboard.writeText(secret.id);
onClick={onDeleteSecret}
createNotification({
title: "Secret ID Copied",
text: "The secret ID has been copied to your clipboard.",
type: "success"
});
}}
> >
<Tooltip content="Delete Secret"> <FontAwesomeIcon icon={faCopy} />
<FontAwesomeIcon icon={faTrash} />
</Tooltip>
</IconButton> </IconButton>
)} </Tooltip>
</ProjectPermissionCan> <ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<Tooltip content="Delete Secret">
<IconButton
colorSchema="danger"
variant="outline_bg"
ariaLabel="Delete Secret"
className="border border-mineshaft-600 bg-mineshaft-700 hover:border-red-500/70 hover:bg-red-600/20"
isDisabled={!isAllowed}
onClick={onDeleteSecret}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -238,10 +238,12 @@ export const SecretListView = ({
if (!isReminderEvent) { if (!isReminderEvent) {
handlePopUpClose("secretDetail"); handlePopUpClose("secretDetail");
} }
let successMessage; let successMessage;
if (isReminderEvent) { if (isReminderEvent) {
successMessage = reminderRepeatDays ? "Successfully saved secret reminder" : "Successfully deleted secret reminder"; successMessage = reminderRepeatDays
? "Successfully saved secret reminder"
: "Successfully deleted secret reminder";
} else { } else {
successMessage = "Successfully saved secrets"; successMessage = "Successfully saved secrets";
} }