mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #4369 from Infisical/modify-access-requests
feature(access-requests): allow editing of access request by admin reviewers
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
|
||||||
|
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
|
||||||
|
|
||||||
|
if (!hasEditNoteCol || !hasEditedByUserId) {
|
||||||
|
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
|
||||||
|
if (!hasEditedByUserId) {
|
||||||
|
t.uuid("editedByUserId").nullable();
|
||||||
|
t.foreign("editedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEditNoteCol) {
|
||||||
|
t.string("editNote").nullable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
|
||||||
|
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
|
||||||
|
|
||||||
|
if (hasEditNoteCol || hasEditedByUserId) {
|
||||||
|
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
|
||||||
|
if (hasEditedByUserId) {
|
||||||
|
t.dropColumn("editedByUserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasEditNoteCol) {
|
||||||
|
t.dropColumn("editNote");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ export const AccessApprovalRequestsSchema = z.object({
|
|||||||
requestedByUserId: z.string().uuid(),
|
requestedByUserId: z.string().uuid(),
|
||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
privilegeDeletedAt: z.date().nullable().optional(),
|
privilegeDeletedAt: z.date().nullable().optional(),
|
||||||
status: z.string().default("pending")
|
status: z.string().default("pending"),
|
||||||
|
editedByUserId: z.string().uuid().nullable().optional(),
|
||||||
|
editNote: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>;
|
export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
|
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
|
||||||
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
|
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
|
||||||
|
import { ms } from "@app/lib/ms";
|
||||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
@@ -26,7 +27,23 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
body: z.object({
|
body: z.object({
|
||||||
permissions: z.any().array(),
|
permissions: z.any().array(),
|
||||||
isTemporary: z.boolean(),
|
isTemporary: z.boolean(),
|
||||||
temporaryRange: z.string().optional(),
|
temporaryRange: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val, ctx) => {
|
||||||
|
if (!val || val === "permanent") return undefined;
|
||||||
|
|
||||||
|
const parsedMs = ms(val);
|
||||||
|
|
||||||
|
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}),
|
||||||
note: z.string().max(255).optional()
|
note: z.string().max(255).optional()
|
||||||
}),
|
}),
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
@@ -190,4 +207,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
return { review };
|
return { review };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:requestId",
|
||||||
|
method: "PATCH",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
requestId: z.string().trim()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
temporaryRange: z.string().transform((val, ctx) => {
|
||||||
|
const parsedMs = ms(val);
|
||||||
|
|
||||||
|
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}),
|
||||||
|
editNote: z.string().max(255)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: AccessApprovalRequestsSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
temporaryRange: req.body.temporaryRange,
|
||||||
|
editNote: req.body.editNote,
|
||||||
|
requestId: req.params.requestId
|
||||||
|
});
|
||||||
|
return { approval: request };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
|||||||
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
|
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
|
||||||
accessApprovalRequestReviewerDAL: Pick<
|
accessApprovalRequestReviewerDAL: Pick<
|
||||||
TAccessApprovalRequestReviewerDALFactory,
|
TAccessApprovalRequestReviewerDALFactory,
|
||||||
"create" | "find" | "findOne" | "transaction"
|
"create" | "find" | "findOne" | "transaction" | "delete"
|
||||||
>;
|
>;
|
||||||
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
|
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
|
||||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
|
||||||
@@ -301,6 +301,155 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
return { request: approval };
|
return { request: approval };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateAccessApprovalRequest: TAccessApprovalRequestServiceFactory["updateAccessApprovalRequest"] = async ({
|
||||||
|
temporaryRange,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
editNote,
|
||||||
|
requestId
|
||||||
|
}) => {
|
||||||
|
const cfg = getConfig();
|
||||||
|
|
||||||
|
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
|
||||||
|
if (!accessApprovalRequest) {
|
||||||
|
throw new NotFoundError({ message: `Access request with ID '${requestId}' not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { policy, requestedByUser } = accessApprovalRequest;
|
||||||
|
if (policy.deletedAt) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "The policy associated with this access request has been deleted."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { membership, hasRole } = await permissionService.getProjectPermission({
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId: accessApprovalRequest.projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new ForbiddenRequestError({ message: "You are not a member of this project" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
|
||||||
|
|
||||||
|
if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) {
|
||||||
|
throw new ForbiddenRequestError({ message: "You are not authorized to modify this request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await projectDAL.findById(accessApprovalRequest.projectId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `The project associated with this access request was not found. [projectId=${accessApprovalRequest.projectId}]`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessApprovalRequest.status !== ApprovalStatus.PENDING) {
|
||||||
|
throw new BadRequestError({ message: "The request has been closed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const editedByUser = await userDAL.findById(actorId);
|
||||||
|
|
||||||
|
if (!editedByUser) throw new NotFoundError({ message: "Editing user not found" });
|
||||||
|
|
||||||
|
if (accessApprovalRequest.isTemporary && accessApprovalRequest.temporaryRange) {
|
||||||
|
if (ms(temporaryRange) > ms(accessApprovalRequest.temporaryRange)) {
|
||||||
|
throw new BadRequestError({ message: "Updated access duration must be less than current access duration" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({
|
||||||
|
permissions: accessApprovalRequest.permissions
|
||||||
|
});
|
||||||
|
|
||||||
|
const approval = await accessApprovalRequestDAL.transaction(async (tx) => {
|
||||||
|
const approvalRequest = await accessApprovalRequestDAL.updateById(
|
||||||
|
requestId,
|
||||||
|
{
|
||||||
|
temporaryRange,
|
||||||
|
isTemporary: true,
|
||||||
|
editNote,
|
||||||
|
editedByUserId: actorId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
// reset review progress
|
||||||
|
await accessApprovalRequestReviewerDAL.delete(
|
||||||
|
{
|
||||||
|
requestId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
|
||||||
|
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
|
||||||
|
const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`;
|
||||||
|
|
||||||
|
await triggerWorkflowIntegrationNotification({
|
||||||
|
input: {
|
||||||
|
notification: {
|
||||||
|
type: TriggerFeature.ACCESS_REQUEST_UPDATED,
|
||||||
|
payload: {
|
||||||
|
projectName: project.name,
|
||||||
|
requesterFullName,
|
||||||
|
isTemporary: true,
|
||||||
|
requesterEmail: requestedByUser.email as string,
|
||||||
|
secretPath,
|
||||||
|
environment: envSlug,
|
||||||
|
permissions: accessTypes,
|
||||||
|
approvalUrl,
|
||||||
|
editNote,
|
||||||
|
editorEmail: editedByUser.email as string,
|
||||||
|
editorFullName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
projectId: project.id
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
projectDAL,
|
||||||
|
projectSlackConfigDAL,
|
||||||
|
kmsService,
|
||||||
|
microsoftTeamsService,
|
||||||
|
projectMicrosoftTeamsConfigDAL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await smtpService.sendMail({
|
||||||
|
recipients: policy.approvers
|
||||||
|
.filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id)
|
||||||
|
.map((approver) => approver.email!),
|
||||||
|
subjectLine: "Access Approval Request Updated",
|
||||||
|
substitutions: {
|
||||||
|
projectName: project.name,
|
||||||
|
requesterFullName,
|
||||||
|
requesterEmail: requestedByUser.email,
|
||||||
|
isTemporary: true,
|
||||||
|
expiresIn: msFn(ms(temporaryRange || ""), { long: true }),
|
||||||
|
secretPath,
|
||||||
|
environment: envSlug,
|
||||||
|
permissions: accessTypes,
|
||||||
|
approvalUrl,
|
||||||
|
editNote,
|
||||||
|
editorFullName,
|
||||||
|
editorEmail: editedByUser.email
|
||||||
|
},
|
||||||
|
template: SmtpTemplates.AccessApprovalRequestUpdated
|
||||||
|
});
|
||||||
|
|
||||||
|
return approvalRequest;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { request: approval };
|
||||||
|
};
|
||||||
|
|
||||||
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
|
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
authorUserId,
|
authorUserId,
|
||||||
@@ -650,6 +799,7 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
createAccessApprovalRequest,
|
createAccessApprovalRequest,
|
||||||
|
updateAccessApprovalRequest,
|
||||||
listApprovalRequests,
|
listApprovalRequests,
|
||||||
reviewAccessRequest,
|
reviewAccessRequest,
|
||||||
getCount
|
getCount
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = {
|
|||||||
note?: string;
|
note?: string;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TUpdateAccessApprovalRequestDTO = {
|
||||||
|
requestId: string;
|
||||||
|
temporaryRange: string;
|
||||||
|
editNote: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TListApprovalRequestsDTO = {
|
export type TListApprovalRequestsDTO = {
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
authorUserId?: string;
|
authorUserId?: string;
|
||||||
@@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory {
|
|||||||
privilegeDeletedAt?: Date | null | undefined;
|
privilegeDeletedAt?: Date | null | undefined;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
updateAccessApprovalRequest: (arg: TUpdateAccessApprovalRequestDTO) => Promise<{
|
||||||
|
request: {
|
||||||
|
status: string;
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
policyId: string;
|
||||||
|
isTemporary: boolean;
|
||||||
|
requestedByUserId: string;
|
||||||
|
privilegeId?: string | null | undefined;
|
||||||
|
requestedBy?: string | null | undefined;
|
||||||
|
temporaryRange?: string | null | undefined;
|
||||||
|
permissions?: unknown;
|
||||||
|
note?: string | null | undefined;
|
||||||
|
privilegeDeletedAt?: Date | null | undefined;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{
|
listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{
|
||||||
requests: {
|
requests: {
|
||||||
policy: {
|
policy: {
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
|
|||||||
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
|
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
|
||||||
|
|
||||||
if (slackConfig) {
|
if (slackConfig) {
|
||||||
if (notification.type === TriggerFeature.ACCESS_REQUEST) {
|
if (
|
||||||
|
notification.type === TriggerFeature.ACCESS_REQUEST ||
|
||||||
|
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
|
||||||
|
) {
|
||||||
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
|
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
|
||||||
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
|
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
|
||||||
await sendSlackNotification({
|
await sendSlackNotification({
|
||||||
@@ -50,7 +53,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (microsoftTeamsConfig) {
|
if (microsoftTeamsConfig) {
|
||||||
if (notification.type === TriggerFeature.ACCESS_REQUEST) {
|
if (
|
||||||
|
notification.type === TriggerFeature.ACCESS_REQUEST ||
|
||||||
|
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
|
||||||
|
) {
|
||||||
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
|
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
|
||||||
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
|
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
|
||||||
microsoftTeamsConfig.accessRequestChannels
|
microsoftTeamsConfig.accessRequestChannels
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
|
|||||||
|
|
||||||
export enum TriggerFeature {
|
export enum TriggerFeature {
|
||||||
SECRET_APPROVAL = "secret-approval",
|
SECRET_APPROVAL = "secret-approval",
|
||||||
ACCESS_REQUEST = "access-request"
|
ACCESS_REQUEST = "access-request",
|
||||||
|
ACCESS_REQUEST_UPDATED = "access-request-updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TNotification =
|
export type TNotification =
|
||||||
@@ -34,6 +35,22 @@ export type TNotification =
|
|||||||
approvalUrl: string;
|
approvalUrl: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: TriggerFeature.ACCESS_REQUEST_UPDATED;
|
||||||
|
payload: {
|
||||||
|
requesterFullName: string;
|
||||||
|
requesterEmail: string;
|
||||||
|
isTemporary: boolean;
|
||||||
|
secretPath: string;
|
||||||
|
environment: string;
|
||||||
|
projectName: string;
|
||||||
|
permissions: string[];
|
||||||
|
approvalUrl: string;
|
||||||
|
editNote?: string;
|
||||||
|
editorFullName?: string;
|
||||||
|
editorEmail?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TTriggerWorkflowNotificationDTO = {
|
export type TTriggerWorkflowNotificationDTO = {
|
||||||
|
|||||||
@@ -462,6 +462,54 @@ export const buildTeamsPayload = (notification: TNotification) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
|
||||||
|
const { payload } = notification;
|
||||||
|
|
||||||
|
const adaptiveCard = {
|
||||||
|
type: "AdaptiveCard",
|
||||||
|
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
version: "1.5",
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
type: "TextBlock",
|
||||||
|
text: "Updated access approval request pending for review",
|
||||||
|
weight: "Bolder",
|
||||||
|
size: "Large"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "TextBlock",
|
||||||
|
text: `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
|
||||||
|
payload.isTemporary ? "temporary" : "permanent"
|
||||||
|
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.`,
|
||||||
|
wrap: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "TextBlock",
|
||||||
|
text: `The following permissions are requested: ${payload.permissions.join(", ")}`,
|
||||||
|
wrap: true
|
||||||
|
},
|
||||||
|
payload.editNote
|
||||||
|
? {
|
||||||
|
type: "TextBlock",
|
||||||
|
text: `**Editor Note**: ${payload.editNote}`,
|
||||||
|
wrap: true
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
].filter(Boolean),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "Action.OpenUrl",
|
||||||
|
title: "View request in Infisical",
|
||||||
|
url: payload.approvalUrl
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
adaptiveCard
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Teams notification type not supported."
|
message: "Teams notification type not supported."
|
||||||
|
|||||||
@@ -115,6 +115,44 @@ User Note: ${payload.note}`
|
|||||||
payloadBlocks
|
payloadBlocks
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
|
||||||
|
const { payload } = notification;
|
||||||
|
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
|
||||||
|
payload.isTemporary ? "temporary" : "permanent"
|
||||||
|
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
|
||||||
|
|
||||||
|
The following permissions are requested: ${payload.permissions.join(", ")}
|
||||||
|
|
||||||
|
View the request and approve or deny it <${payload.approvalUrl}|here>.${
|
||||||
|
payload.editNote
|
||||||
|
? `
|
||||||
|
Editor Note: ${payload.editNote}`
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const payloadBlocks = [
|
||||||
|
{
|
||||||
|
type: "header",
|
||||||
|
text: {
|
||||||
|
type: "plain_text",
|
||||||
|
text: "Updated access approval request pending for review",
|
||||||
|
emoji: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
text: {
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: messageBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
payloadMessage: messageBody,
|
||||||
|
payloadBlocks
|
||||||
|
};
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Slack notification type not supported."
|
message: "Slack notification type not supported."
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Heading, Section, Text } from "@react-email/components";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { BaseButton } from "./BaseButton";
|
||||||
|
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
|
||||||
|
import { BaseLink } from "./BaseLink";
|
||||||
|
|
||||||
|
interface AccessApprovalRequestUpdatedTemplateProps
|
||||||
|
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
|
||||||
|
projectName: string;
|
||||||
|
requesterFullName: string;
|
||||||
|
requesterEmail: string;
|
||||||
|
isTemporary: boolean;
|
||||||
|
secretPath: string;
|
||||||
|
environment: string;
|
||||||
|
expiresIn: string;
|
||||||
|
permissions: string[];
|
||||||
|
editNote: string;
|
||||||
|
editorFullName: string;
|
||||||
|
editorEmail: string;
|
||||||
|
approvalUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessApprovalRequestUpdatedTemplate = ({
|
||||||
|
projectName,
|
||||||
|
siteUrl,
|
||||||
|
requesterFullName,
|
||||||
|
requesterEmail,
|
||||||
|
isTemporary,
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
expiresIn,
|
||||||
|
permissions,
|
||||||
|
editNote,
|
||||||
|
editorEmail,
|
||||||
|
editorFullName,
|
||||||
|
approvalUrl
|
||||||
|
}: AccessApprovalRequestUpdatedTemplateProps) => {
|
||||||
|
return (
|
||||||
|
<BaseEmailWrapper
|
||||||
|
title="Access Approval Request Update"
|
||||||
|
preview="An access approval request was updated and requires your review."
|
||||||
|
siteUrl={siteUrl}
|
||||||
|
>
|
||||||
|
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
||||||
|
An access approval request was updated and is pending your review for the project <strong>{projectName}</strong>
|
||||||
|
</Heading>
|
||||||
|
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
||||||
|
<Text className="text-black text-[14px] leading-[24px]">
|
||||||
|
<strong>{editorFullName}</strong> (<BaseLink href={`mailto:${editorEmail}`}>{editorEmail}</BaseLink>) has
|
||||||
|
updated the access request submitted by <strong>{requesterFullName}</strong> (
|
||||||
|
<BaseLink href={`mailto:${requesterEmail}`}>{requesterEmail}</BaseLink>) for <strong>{secretPath}</strong> in
|
||||||
|
the <strong>{environment}</strong> environment.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isTemporary && (
|
||||||
|
<Text className="text-[14px] text-red-600 leading-[24px]">
|
||||||
|
<strong>This access will expire {expiresIn} after approval.</strong>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text className="text-[14px] leading-[24px] mb-[4px]">
|
||||||
|
<strong>The following permissions are requested:</strong>
|
||||||
|
</Text>
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<Text key={permission} className="text-[14px] my-[2px] leading-[24px]">
|
||||||
|
- {permission}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text className="text-[14px] text-slate-700 leading-[24px]">
|
||||||
|
<strong className="text-black">Editor Note:</strong> "{editNote}"
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="text-center">
|
||||||
|
<BaseButton href={approvalUrl}>Review Request</BaseButton>
|
||||||
|
</Section>
|
||||||
|
</BaseEmailWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessApprovalRequestUpdatedTemplate;
|
||||||
|
|
||||||
|
AccessApprovalRequestUpdatedTemplate.PreviewProps = {
|
||||||
|
requesterFullName: "Abigail Williams",
|
||||||
|
requesterEmail: "abigail@infisical.com",
|
||||||
|
isTemporary: true,
|
||||||
|
secretPath: "/api/secrets",
|
||||||
|
environment: "Production",
|
||||||
|
siteUrl: "https://infisical.com",
|
||||||
|
projectName: "Example Project",
|
||||||
|
expiresIn: "1 day",
|
||||||
|
permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"],
|
||||||
|
editNote: "Too permissive, they only need 3 days",
|
||||||
|
editorEmail: "john@infisical.com",
|
||||||
|
editorFullName: "John Smith"
|
||||||
|
} as AccessApprovalRequestUpdatedTemplateProps;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./AccessApprovalRequestTemplate";
|
export * from "./AccessApprovalRequestTemplate";
|
||||||
|
export * from "./AccessApprovalRequestUpdatedTemplate";
|
||||||
export * from "./EmailMfaTemplate";
|
export * from "./EmailMfaTemplate";
|
||||||
export * from "./EmailVerificationTemplate";
|
export * from "./EmailVerificationTemplate";
|
||||||
export * from "./ExternalImportFailedTemplate";
|
export * from "./ExternalImportFailedTemplate";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AccessApprovalRequestTemplate,
|
AccessApprovalRequestTemplate,
|
||||||
|
AccessApprovalRequestUpdatedTemplate,
|
||||||
EmailMfaTemplate,
|
EmailMfaTemplate,
|
||||||
EmailVerificationTemplate,
|
EmailVerificationTemplate,
|
||||||
ExternalImportFailedTemplate,
|
ExternalImportFailedTemplate,
|
||||||
@@ -54,6 +55,7 @@ export enum SmtpTemplates {
|
|||||||
EmailMfa = "emailMfa",
|
EmailMfa = "emailMfa",
|
||||||
UnlockAccount = "unlockAccount",
|
UnlockAccount = "unlockAccount",
|
||||||
AccessApprovalRequest = "accessApprovalRequest",
|
AccessApprovalRequest = "accessApprovalRequest",
|
||||||
|
AccessApprovalRequestUpdated = "accessApprovalRequestUpdated",
|
||||||
AccessSecretRequestBypassed = "accessSecretRequestBypassed",
|
AccessSecretRequestBypassed = "accessSecretRequestBypassed",
|
||||||
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview",
|
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview",
|
||||||
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
|
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
|
||||||
@@ -96,6 +98,7 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
|
|||||||
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
|
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
|
||||||
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
|
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
|
||||||
[SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate,
|
[SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate,
|
||||||
|
[SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate,
|
||||||
[SmtpTemplates.EmailVerification]: EmailVerificationTemplate,
|
[SmtpTemplates.EmailVerification]: EmailVerificationTemplate,
|
||||||
[SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate,
|
[SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate,
|
||||||
[SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate,
|
[SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { FormLabel, Tooltip } from "../v2";
|
import { FormLabel, Tooltip } from "../v2";
|
||||||
@@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
|
|||||||
label={label}
|
label={label}
|
||||||
icon={
|
icon={
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
className="max-w-lg"
|
||||||
content={
|
content={
|
||||||
<span>
|
<span>
|
||||||
|
Examples: 30m, 1h, 3d, etc.{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary-700"
|
className="text-primary-500 hover:text-mineshaft-100"
|
||||||
>
|
>
|
||||||
More
|
See More Examples{" "}
|
||||||
|
<FontAwesomeIcon size="xs" className="mt-0.5" icon={faArrowUpRightFromSquare} />
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
|
|||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faQuestionCircle}
|
icon={faQuestionCircle}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="relative bottom-px right-1"
|
className="relative right-1 mt-0.5 text-mineshaft-300"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { apiRequest } from "@app/config/request";
|
|||||||
import { accessApprovalKeys } from "./queries";
|
import { accessApprovalKeys } from "./queries";
|
||||||
import {
|
import {
|
||||||
TAccessApproval,
|
TAccessApproval,
|
||||||
|
TAccessApprovalRequest,
|
||||||
TCreateAccessPolicyDTO,
|
TCreateAccessPolicyDTO,
|
||||||
TCreateAccessRequestDTO,
|
TCreateAccessRequestDTO,
|
||||||
TDeleteSecretPolicyDTO,
|
TDeleteSecretPolicyDTO,
|
||||||
TUpdateAccessPolicyDTO
|
TUpdateAccessPolicyDTO,
|
||||||
|
TUpdateAccessRequestDTO
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const useCreateAccessApprovalPolicy = () => {
|
export const useCreateAccessApprovalPolicy = () => {
|
||||||
@@ -134,6 +136,25 @@ export const useCreateAccessRequest = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useUpdateAccessRequest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<TAccessApprovalRequest, object, TUpdateAccessRequestDTO>({
|
||||||
|
mutationFn: async ({ requestId, ...payload }) => {
|
||||||
|
const { data } = await apiRequest.patch<{ approval: TAccessApprovalRequest }>(
|
||||||
|
`/api/v1/access-approvals/requests/${requestId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.approval;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useReviewAccessRequest = () => {
|
export const useReviewAccessRequest = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation<
|
return useMutation<
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const accessApprovalKeys = {
|
|||||||
envSlug?: string,
|
envSlug?: string,
|
||||||
requestedBy?: string,
|
requestedBy?: string,
|
||||||
bypassReason?: string
|
bypassReason?: string
|
||||||
) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const,
|
) => ["access-approvals-requests", projectSlug, envSlug, requestedBy, bypassReason] as const,
|
||||||
getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) =>
|
getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) =>
|
||||||
[{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const
|
[{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export type TAccessApprovalRequest = {
|
|||||||
}[];
|
}[];
|
||||||
|
|
||||||
note?: string;
|
note?: string;
|
||||||
|
editNote?: string;
|
||||||
|
editedByUserId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAccessApproval = {
|
export type TAccessApproval = {
|
||||||
@@ -146,6 +148,13 @@ export type TCreateAccessRequestDTO = {
|
|||||||
note?: string;
|
note?: string;
|
||||||
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
||||||
|
|
||||||
|
export type TUpdateAccessRequestDTO = {
|
||||||
|
requestId: string;
|
||||||
|
editNote: string;
|
||||||
|
temporaryRange: string;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TGetAccessApprovalRequestsDTO = {
|
export type TGetAccessApprovalRequestsDTO = {
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
policyId?: string;
|
policyId?: string;
|
||||||
|
|||||||
@@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({
|
|||||||
setSelectedRequest(null);
|
setSelectedRequest(null);
|
||||||
refetchRequests();
|
refetchRequests();
|
||||||
}}
|
}}
|
||||||
|
onUpdate={(request) => {
|
||||||
|
// scott: this isn't ideal but our current use of state makes this complicated...
|
||||||
|
// we shouldn't be using state like this...
|
||||||
|
handleSelectRequest({
|
||||||
|
...selectedRequest,
|
||||||
|
isTemporary: request.isTemporary,
|
||||||
|
temporaryRange: request.temporaryRange,
|
||||||
|
reviewers: []
|
||||||
|
});
|
||||||
|
}}
|
||||||
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
canBypass={generateRequestDetails(selectedRequest).canBypass}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalClose,
|
||||||
|
ModalContent,
|
||||||
|
TextArea
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useUpdateAccessRequest } from "@app/hooks/api/accessApproval/mutation";
|
||||||
|
import { accessApprovalKeys } from "@app/hooks/api/accessApproval/queries";
|
||||||
|
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
||||||
|
|
||||||
|
type ContentProps = {
|
||||||
|
accessRequest: TAccessApprovalRequest;
|
||||||
|
onComplete: (request: TAccessApprovalRequest) => void;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditSchema = z.object({
|
||||||
|
temporaryRange: z
|
||||||
|
.string()
|
||||||
|
.nonempty("Required")
|
||||||
|
.transform((val, ctx) => {
|
||||||
|
const parsedMs = ms(val);
|
||||||
|
|
||||||
|
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
"Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}),
|
||||||
|
editNote: z.string().nonempty("Required")
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof EditSchema>;
|
||||||
|
|
||||||
|
const Content = ({ accessRequest, onComplete, projectSlug }: ContentProps) => {
|
||||||
|
const update = useUpdateAccessRequest();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(EditSchema),
|
||||||
|
defaultValues: {
|
||||||
|
temporaryRange: accessRequest.temporaryRange ?? "1h",
|
||||||
|
editNote: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (form: FormData) => {
|
||||||
|
try {
|
||||||
|
const request = await update.mutateAsync({
|
||||||
|
requestId: accessRequest.id,
|
||||||
|
projectSlug,
|
||||||
|
...form
|
||||||
|
});
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug)
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Access request updated successfully."
|
||||||
|
});
|
||||||
|
onComplete(request);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to update access request"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="mb-4 flex w-full items-start rounded-md border border-yellow/50 bg-yellow/30 px-4 py-2 text-sm text-yellow-200">
|
||||||
|
<FontAwesomeIcon icon={faWarning} className="mr-2.5 mt-1 text-base text-yellow" />
|
||||||
|
Updating this access request will restart the review process and require all approvers to
|
||||||
|
re-approve it.
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue="1h"
|
||||||
|
name="temporaryRange"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Access Duration" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText={`Must be less than current access duration: ${accessRequest.isTemporary ? accessRequest.temporaryRange : "Permanent"}`}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="editNote"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Reason for Editing"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
className="!resize-none"
|
||||||
|
rows={4}
|
||||||
|
{...field}
|
||||||
|
placeholder="Provide a reason for updating this request..."
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex gap-x-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline_bg"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Update Request
|
||||||
|
</Button>
|
||||||
|
<ModalClose asChild>
|
||||||
|
<Button variant="plain" colorSchema="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</ModalClose>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
accessRequest?: TAccessApprovalRequest;
|
||||||
|
onComplete: (request: TAccessApprovalRequest) => void;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditAccessRequestModal = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
accessRequest,
|
||||||
|
onComplete,
|
||||||
|
projectSlug
|
||||||
|
}: Props) => {
|
||||||
|
if (!accessRequest) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent
|
||||||
|
title="Edit Access Request"
|
||||||
|
subTitle="Modify this access request for re-approval."
|
||||||
|
>
|
||||||
|
<Content
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
accessRequest={accessRequest}
|
||||||
|
onComplete={(request) => {
|
||||||
|
onComplete(request);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
faBan,
|
faBan,
|
||||||
faCheck,
|
faCheck,
|
||||||
|
faEdit,
|
||||||
faHourglass,
|
faHourglass,
|
||||||
faTriangleExclamation
|
faTriangleExclamation
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
FormControl,
|
FormControl,
|
||||||
GenericFieldLabel,
|
GenericFieldLabel,
|
||||||
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { Badge } from "@app/components/v2/Badge";
|
import { Badge } from "@app/components/v2/Badge";
|
||||||
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
|
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
|
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
|
||||||
import {
|
import {
|
||||||
Approver,
|
Approver,
|
||||||
@@ -32,6 +35,7 @@ import {
|
|||||||
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
||||||
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||||
import { groupBy } from "@app/lib/fn/array";
|
import { groupBy } from "@app/lib/fn/array";
|
||||||
|
import { EditAccessRequestModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal";
|
||||||
|
|
||||||
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
||||||
if (status === ApprovalStatus.APPROVED)
|
if (status === ApprovalStatus.APPROVED)
|
||||||
@@ -62,7 +66,8 @@ export const ReviewAccessRequestModal = ({
|
|||||||
selectedEnvSlug,
|
selectedEnvSlug,
|
||||||
canBypass,
|
canBypass,
|
||||||
policies = [],
|
policies = [],
|
||||||
members = []
|
members = [],
|
||||||
|
onUpdate
|
||||||
}: {
|
}: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
@@ -78,6 +83,7 @@ export const ReviewAccessRequestModal = ({
|
|||||||
canBypass: boolean;
|
canBypass: boolean;
|
||||||
policies: TAccessApprovalPolicy[];
|
policies: TAccessApprovalPolicy[];
|
||||||
members: TWorkspaceUser[];
|
members: TWorkspaceUser[];
|
||||||
|
onUpdate: (request: TAccessApprovalRequest) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
||||||
const [bypassApproval, setBypassApproval] = useState(false);
|
const [bypassApproval, setBypassApproval] = useState(false);
|
||||||
@@ -86,6 +92,8 @@ export const ReviewAccessRequestModal = ({
|
|||||||
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
|
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["editRequest"] as const);
|
||||||
|
|
||||||
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||||
|
|
||||||
const accessDetails = {
|
const accessDetails = {
|
||||||
@@ -121,16 +129,9 @@ export const ReviewAccessRequestModal = ({
|
|||||||
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
||||||
return "Permanent";
|
return "Permanent";
|
||||||
|
|
||||||
// convert the range to human readable format
|
return `Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||||
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
|
long: true
|
||||||
|
})} after approval`;
|
||||||
return (
|
|
||||||
<Badge>
|
|
||||||
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
|
||||||
long: true
|
|
||||||
})} after approval`}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const reviewAccessRequest = useReviewAccessRequest();
|
const reviewAccessRequest = useReviewAccessRequest();
|
||||||
@@ -286,7 +287,33 @@ export const ReviewAccessRequestModal = ({
|
|||||||
<GenericFieldLabel truncate label="Secret Path">
|
<GenericFieldLabel truncate label="Secret Path">
|
||||||
{accessDetails.secretPath}
|
{accessDetails.secretPath}
|
||||||
</GenericFieldLabel>
|
</GenericFieldLabel>
|
||||||
<GenericFieldLabel label="Access Type">{getAccessLabel()}</GenericFieldLabel>
|
<GenericFieldLabel label="Access Duration">
|
||||||
|
<div className="flex h-min gap-1">
|
||||||
|
{getAccessLabel()}
|
||||||
|
{request.isApprover && request.status === ApprovalStatus.PENDING && (
|
||||||
|
<>
|
||||||
|
<EditAccessRequestModal
|
||||||
|
isOpen={popUp.editRequest.isOpen}
|
||||||
|
onOpenChange={(open) => handlePopUpToggle("editRequest", open)}
|
||||||
|
accessRequest={request}
|
||||||
|
onComplete={onUpdate}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
/>
|
||||||
|
<Tooltip content="Edit Access Duration">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => handlePopUpOpen("editRequest")}
|
||||||
|
variant="plain"
|
||||||
|
size="xs"
|
||||||
|
tabIndex={-1}
|
||||||
|
ariaLabel="Edit access duration"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEdit} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GenericFieldLabel>
|
||||||
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
|
<GenericFieldLabel label="Permission">{requestedAccess}</GenericFieldLabel>
|
||||||
{request.note && (
|
{request.note && (
|
||||||
<GenericFieldLabel className="col-span-full" label="Note">
|
<GenericFieldLabel className="col-span-full" label="Note">
|
||||||
|
|||||||
Reference in New Issue
Block a user