From 5c9243d6911bcfa823ead3f82c714969e446da66 Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 7 Aug 2025 23:15:48 -0400 Subject: [PATCH 1/8] feat(access-policies): Allow policy limits on access request times --- ...808021941_access-policy-max-time-period.ts | 19 ++++++++ .../db/schemas/access-approval-policies.ts | 3 +- .../v1/access-approval-policy-router.ts | 43 +++++++++++++++++-- .../v1/access-approval-request-router.ts | 3 +- .../access-approval-policy-dal.ts | 3 ++ .../access-approval-policy-service.ts | 12 ++++-- .../access-approval-policy-types.ts | 13 +++++- .../access-approval-request-dal.ts | 14 ++++-- .../access-approval-request-service.ts | 9 ++++ .../access-approval-request-types.ts | 1 + .../src/hooks/api/accessApproval/mutation.tsx | 12 ++++-- .../src/hooks/api/accessApproval/types.ts | 4 ++ .../SpecificPrivilegeSection.tsx | 18 ++++++++ .../components/AccessPolicyModal.tsx | 22 +++++++++- .../SecretListView/SecretDetailSidebar.tsx | 13 ++++-- 15 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 backend/src/db/migrations/20250808021941_access-policy-max-time-period.ts diff --git a/backend/src/db/migrations/20250808021941_access-policy-max-time-period.ts b/backend/src/db/migrations/20250808021941_access-policy-max-time-period.ts new file mode 100644 index 000000000..218260b3e --- /dev/null +++ b/backend/src/db/migrations/20250808021941_access-policy-max-time-period.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas/models"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod"))) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.string("maxTimePeriod").nullable(); // Ex: 1h - Null is permanent + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod")) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropColumn("maxTimePeriod"); + }); + } +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index ea57c54d2..66ed79ab7 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -17,7 +17,8 @@ export const AccessApprovalPoliciesSchema = z.object({ updatedAt: z.date(), enforcementLevel: z.string().default("hard"), deletedAt: z.date().nullable().optional(), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + maxTimePeriod: z.string().nullable().optional() }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index ef44344de..f5278476f 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { removeTrailingSlash } from "@app/lib/fn"; +import { ms } from "@app/lib/ms"; import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -71,7 +72,24 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + maxTimePeriod: z + .string() + .trim() + .optional() + .transform((val, ctx) => { + if (val === undefined) 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; + }) }) .refine( (val) => Boolean(val.environment) || Boolean(val.environments), @@ -124,7 +142,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .array() .nullable() .optional(), - bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array() + bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array(), + maxTimePeriod: z.string().nullable().optional() }) .array() .nullable() @@ -233,7 +252,24 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi stepNumber: z.number().int() }) .array() + .optional(), + maxTimePeriod: z + .string() + .trim() .optional() + .transform((val, ctx) => { + if (val === undefined) 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; + }) }), response: { 200: z.object({ @@ -314,7 +350,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) .array() .nullable() - .optional() + .optional(), + maxTimePeriod: z.string().nullable().optional() }) }) } diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 7a70d6374..c9e7b0d5c 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -128,7 +128,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv envId: z.string(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), - allowedSelfApprovals: z.boolean() + allowedSelfApprovals: z.boolean(), + maxTimePeriod: z.string().nullable().optional() }), reviewers: z .object({ diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index 9baf762d6..78221d988 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -56,6 +56,7 @@ export interface TAccessApprovalPolicyDALFactory allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; projectId: string; bypassers: ( | { @@ -96,6 +97,7 @@ export interface TAccessApprovalPolicyDALFactory allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; environments: { id: string; name: string; @@ -141,6 +143,7 @@ export interface TAccessApprovalPolicyDALFactory allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; } | undefined >; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 0b3c4e128..95d1a9877 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -100,7 +100,8 @@ export const accessApprovalPolicyServiceFactory = ({ environments, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + maxTimePeriod }) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -219,7 +220,8 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath, name, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + maxTimePeriod }, tx ); @@ -318,7 +320,8 @@ export const accessApprovalPolicyServiceFactory = ({ enforcementLevel, allowedSelfApprovals, approvalsRequired, - environments + environments, + maxTimePeriod }: TUpdateAccessApprovalPolicy) => { const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group); @@ -461,7 +464,8 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath, name, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + maxTimePeriod }, tx ); diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index 27ec228f7..0ed8b0582 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -41,6 +41,7 @@ export type TCreateAccessApprovalPolicy = { enforcementLevel: EnforcementLevel; allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; + maxTimePeriod?: string; } & Omit; export type TUpdateAccessApprovalPolicy = { @@ -60,6 +61,7 @@ export type TUpdateAccessApprovalPolicy = { allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; environments?: string[]; + maxTimePeriod?: string; } & Omit; export type TDeleteAccessApprovalPolicy = { @@ -104,7 +106,8 @@ export interface TAccessApprovalPolicyServiceFactory { environment, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + maxTimePeriod }: TCreateAccessApprovalPolicy) => Promise<{ environment: { name: string; @@ -135,6 +138,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; }>; deleteAccessApprovalPolicy: ({ policyId, @@ -159,6 +163,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; environment: { id: string; name: string; @@ -185,7 +190,8 @@ export interface TAccessApprovalPolicyServiceFactory { enforcementLevel, allowedSelfApprovals, approvalsRequired, - environments + environments, + maxTimePeriod }: TUpdateAccessApprovalPolicy) => Promise<{ environment: { id: string; @@ -208,6 +214,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath?: string | null | undefined; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; }>; getAccessApprovalPolicyByProjectSlug: ({ actorId, @@ -242,6 +249,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; environment: { id: string; name: string; @@ -298,6 +306,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; environment: { id: string; name: string; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 9872df067..68f9240a6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -63,6 +63,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit { @@ -595,7 +600,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel, allowedSelfApprovals: el.policyAllowedSelfApprovals, - deletedAt: el.policyDeletedAt + deletedAt: el.policyDeletedAt, + maxTimePeriod: el.policyMaxTimePeriod }, requestedByUser: { userId: el.requestedByUserId, diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index dcbe717da..58f5c57db 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -156,6 +156,15 @@ export const accessApprovalRequestServiceFactory = ({ throw new BadRequestError({ message: "The policy linked to this request has been deleted" }); } + // Check if the requested time falls under policy.maxTimePeriod + if (policy.maxTimePeriod) { + if (!temporaryRange || ms(temporaryRange) > ms(policy.maxTimePeriod)) { + throw new BadRequestError({ + message: `Requested access time range is limited to ${policy.maxTimePeriod} by policy` + }); + } + } + const approverIds: string[] = []; const approverGroupIds: string[] = []; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index 9066aec8f..88a46192b 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -82,6 +82,7 @@ export interface TAccessApprovalRequestServiceFactory { allowedSelfApprovals: boolean; envId: string; deletedAt: Date | null | undefined; + maxTimePeriod?: string | null; }; projectId: string; environment: string; diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index ad7917a8f..69637243f 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -26,7 +26,8 @@ export const useCreateAccessApprovalPolicy = () => { secretPath, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + maxTimePeriod }) => { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { environments, @@ -38,7 +39,8 @@ export const useCreateAccessApprovalPolicy = () => { name, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + maxTimePeriod }); return data; }, @@ -64,7 +66,8 @@ export const useUpdateAccessApprovalPolicy = () => { enforcementLevel, allowedSelfApprovals, approvalsRequired, - environments + environments, + maxTimePeriod }) => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, @@ -75,7 +78,8 @@ export const useUpdateAccessApprovalPolicy = () => { enforcementLevel, allowedSelfApprovals, approvalsRequired, - environments + environments, + maxTimePeriod }); return data; }, diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index bc569165b..5d2344977 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -18,6 +18,7 @@ export type TAccessApprovalPolicy = { approvers?: Approver[]; bypassers?: Bypasser[]; allowedSelfApprovals: boolean; + maxTimePeriod?: string; }; export enum ApproverType { @@ -93,6 +94,7 @@ export type TAccessApprovalRequest = { enforcementLevel: EnforcementLevel; deletedAt: Date | null; allowedSelfApprovals: boolean; + maxTimePeriod: string | null; }; reviewers: { @@ -173,6 +175,7 @@ export type TCreateAccessPolicyDTO = { enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; + maxTimePeriod?: string; }; export type TUpdateAccessPolicyDTO = { @@ -188,6 +191,7 @@ export type TUpdateAccessPolicyDTO = { // for invalidating list projectSlug: string; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; + maxTimePeriod?: string; }; export type TDeleteSecretPolicyDTO = { diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx index 0256d3219..5dd439744 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -220,6 +220,24 @@ export const SpecificPrivilegeSecretForm = ({ return; } + const policy = policies.find( + (p) => + p.environments.find((e) => e.slug === selectedEnvironment) && p.secretPath === secretPath + ); + + if ( + policy?.maxTimePeriod && + (!data.temporaryAccess.isTemporary || + ms(data.temporaryAccess.temporaryRange) > ms(policy.maxTimePeriod)) + ) { + createNotification({ + type: "error", + text: `Requested access time range is limited to ${policy.maxTimePeriod} by policy`, + title: "Error" + }); + return; + } + const actions = [ { action: ProjectPermissionActions.Read, allowed: data.read }, { action: ProjectPermissionActions.Create, allowed: data.create }, diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index b254b8ac7..d55556470 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -91,7 +91,8 @@ const formSchema = z }) .array() .default([]) - .optional() + .optional(), + maxTimePeriod: z.string().trim().optional() }) .superRefine((data, ctx) => { if (data.policyType === PolicyType.ChangePolicy) { @@ -440,6 +441,25 @@ const Form = ({ )} /> + + {isAccessPolicyType && ( + ( + + + + )} + /> + )} + {!isAccessPolicyType && ( Date: Thu, 7 Aug 2025 23:25:36 -0400 Subject: [PATCH 2/8] Greptile review fixes --- .../v1/access-approval-policy-router.ts | 54 +++++++------------ .../src/hooks/api/accessApproval/types.ts | 2 +- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index f5278476f..a232ae603 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -10,6 +10,24 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; +const maxTimePeriodSchema = z + .string() + .trim() + .optional() + .transform((val, ctx) => { + if (val === undefined) 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; + }); + export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", @@ -73,23 +91,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true), - maxTimePeriod: z - .string() - .trim() - .optional() - .transform((val, ctx) => { - if (val === undefined) 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; - }) + maxTimePeriod: maxTimePeriodSchema }) .refine( (val) => Boolean(val.environment) || Boolean(val.environments), @@ -253,23 +255,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) .array() .optional(), - maxTimePeriod: z - .string() - .trim() - .optional() - .transform((val, ctx) => { - if (val === undefined) 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; - }) + maxTimePeriod: maxTimePeriodSchema }), response: { 200: z.object({ diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 5d2344977..85c565caa 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -94,7 +94,7 @@ export type TAccessApprovalRequest = { enforcementLevel: EnforcementLevel; deletedAt: Date | null; allowedSelfApprovals: boolean; - maxTimePeriod: string | null; + maxTimePeriod?: string | null; }; reviewers: { From 458dcd31c10b59c9681acab26e8a31df4eceadc4 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 11 Aug 2025 14:09:04 -0700 Subject: [PATCH 3/8] feat(secret-import): CSV support (with a base for other matrix-based formats) --- frontend/public/locales/en/translations.json | 4 +- .../src/components/utilities/parseSecrets.ts | 58 +++++ .../SecretDropzone/SecretDropzone.tsx | 226 +++++++++++++++++- .../SecretListView/SecretDetailSidebar.tsx | 13 +- 4 files changed, 284 insertions(+), 17 deletions(-) diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 167e1c7b5..fdfefcf93 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -53,8 +53,8 @@ "project-id": "Project ID", "save-changes": "Save Changes", "saved": "Saved", - "drop-zone": "Drag and drop a .env, .json, or .yml file here.", - "drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.", + "drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.", + "drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.", "role": "Role", "role_admin": "admin", "display-name": "Display Name", diff --git a/frontend/src/components/utilities/parseSecrets.ts b/frontend/src/components/utilities/parseSecrets.ts index 3e5a57edf..e33f25ace 100644 --- a/frontend/src/components/utilities/parseSecrets.ts +++ b/frontend/src/components/utilities/parseSecrets.ts @@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) { return result; } + +function detectSeparator(csvContent: string): string { + const firstLine = csvContent.split("\n")[0]; + const separators = [",", ";", "\t", "|"]; + + const counts = separators.map((sep) => ({ + separator: sep, + count: (firstLine.match(new RegExp(`\\${sep}`, "g")) || []).length + })); + + const detected = counts.reduce((max, curr) => (curr.count > max.count ? curr : max)); + + return detected.count > 0 ? detected.separator : ","; +} + +export function parseCsvToMatrix(src: ArrayBuffer | string): string[][] { + let csvContent: string; + if (typeof src === "string") { + csvContent = src; + } else { + csvContent = new TextDecoder("utf-8").decode(src); + } + + const separator = detectSeparator(csvContent); + const lines = csvContent.replace(/\r\n?/g, "\n").split("\n"); + const matrix: string[][] = []; + + lines.forEach((line) => { + if (line.trim() !== "") { + const cells: string[] = []; + let currentCell = ""; + let inQuote = false; + + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuote && nextChar === '"') { + currentCell += '"'; + i += 1; + } else { + inQuote = !inQuote; + } + } else if (char === separator && !inQuote) { + cells.push(currentCell.trim()); + currentCell = ""; + } else { + currentCell += char; + } + } + cells.push(currentCell.trim()); + matrix.push(cells); + } + }); + + return matrix; +} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index 92df89cae..126306e3d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -1,7 +1,14 @@ -import { ChangeEvent, DragEvent } from "react"; +import { ChangeEvent, Dispatch, DragEvent, SetStateAction, useState } from "react"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; -import { faPlus, faUpload } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowRight, + faAsterisk, + faComment, + faKey, + faPlus, + faUpload +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQueryClient } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; @@ -9,8 +16,22 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; // TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality -import { parseDotEnv, parseJson, parseYaml } from "@app/components/utilities/parseSecrets"; -import { Button, Lottie, Modal, ModalContent } from "@app/components/v2"; +import { + parseCsvToMatrix, + parseDotEnv, + parseJson, + parseYaml +} from "@app/components/utilities/parseSecrets"; +import { + Badge, + Button, + FormLabel, + Lottie, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; @@ -38,6 +59,84 @@ type Props = { isProtectedBranch?: boolean; }; +type SecretMatrixMap = { + key: number; + value: number | null; + comment: number | null; +}; + +const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const; + +const MatrixImportModalTableRow = ({ + importSecretMatrixMap, + setImportSecretMatrixMap, + headers, + mapKey +}: { + importSecretMatrixMap: SecretMatrixMap; + setImportSecretMatrixMap: Dispatch>; + headers: string[]; + mapKey: keyof SecretMatrixMap; +}) => { + return ( + + + + + +
+ +
+ + +
+ + {mapKey === "key" && ( + <> + + Secret Key + + )} + {mapKey === "value" && ( + <> + + Secret Value + + )} + {mapKey === "comment" && ( + <> + + Comment + + )} + +
+ + + ); +}; + export const SecretDropzone = ({ isSmaller, environments = [], @@ -50,11 +149,14 @@ export const SecretDropzone = ({ const [isDragActive, setDragActive] = useToggle(); const [isLoading, setIsLoading] = useToggle(); - const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "importSecEnv", - "confirmUpload", - "pasteSecEnv" - ] as const); + // Maps matrix columns to parts of a secret + const [importSecretMatrixMap, setImportSecretMatrixMap] = useState({ + key: 0, + value: null, + comment: null + }); + + const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys); const queryClient = useQueryClient(); const { openPopUp } = usePopUpAction(); @@ -136,7 +238,7 @@ export const SecretDropzone = ({ }); return; } - // const fileType = file.name.split('.')[1]; + setIsLoading.on(); reader.onload = (event) => { if (!event?.target?.result) return; @@ -154,7 +256,22 @@ export const SecretDropzone = ({ case "application/yaml": env = parseYaml(src); break; - + case "text/csv": { + const fullMatrix = parseCsvToMatrix(src); + if (!fullMatrix.length) { + createNotification({ + type: "error", + text: "Failed to find secrets in CSV file. File might be empty." + }); + setIsLoading.off(); + return; + } + const headers = fullMatrix[0]; + const matrix = fullMatrix.slice(1); + handlePopUpOpen("importMatrixMap", { headers, matrix }); + setIsLoading.off(); + return; + } default: env = parseDotEnv(src); break; @@ -171,6 +288,22 @@ export const SecretDropzone = ({ } }; + const finishMappedMatrixImport = (matrix: string[][]) => { + const env: TParsedEnv = {}; + matrix.forEach((row) => { + const key = row[importSecretMatrixMap.key]; + if (key) { + env[key] = { + value: importSecretMatrixMap.value ? row[importSecretMatrixMap.value] : "", + comments: importSecretMatrixMap.comment ? [row[importSecretMatrixMap.comment]] : [] + }; + } + }); + handlePopUpClose("importMatrixMap"); + setImportSecretMatrixMap({ key: 0, value: null, comment: null }); + handleParsedEnv(env); + }; + const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -293,7 +426,7 @@ export const SecretDropzone = ({ disabled={!isAllowed} type="file" className="absolute h-full w-full cursor-pointer opacity-0" - accept=".txt,.env,.yml,.yaml,.json" + accept=".txt,.env,.yml,.yaml,.json,.csv" onChange={handleFileUpload} /> )} @@ -407,6 +540,75 @@ export const SecretDropzone = ({ )} + + {/* Matrix Import Modal */} + handlePopUpToggle("importMatrixMap", open)} + > + +
+ + + + + + + + + {/* Key */} + + + {/* Value */} + + + {/* Comment */} + + +
+ + + + +
+
+ +
+ +
+
+
); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index d3845fcbc..953176ff5 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -27,7 +27,10 @@ import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { hasSecretReference, SecretReferenceTree } from "@app/components/secrets/SecretReferenceDetails"; +import { + hasSecretReference, + SecretReferenceTree +} from "@app/components/secrets/SecretReferenceDetails"; import { Button, Drawer, @@ -49,8 +52,12 @@ import { Tooltip } from "@app/components/v2"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; - +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { getProjectBaseURL } from "@app/helpers/project"; import { usePopUp } from "@app/hooks"; From ce8653e908a0e30c732b9caa4d9528fc0b00a7ea Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 11 Aug 2025 15:15:48 -0700 Subject: [PATCH 4/8] Address reviews --- backend/src/ee/routes/v1/access-approval-policy-router.ts | 3 ++- .../access-approval-policy/access-approval-policy-types.ts | 4 ++-- frontend/src/hooks/api/accessApproval/types.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index a232ae603..d8d3bf9ed 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -13,9 +13,10 @@ import { AuthMode } from "@app/services/auth/auth-type"; const maxTimePeriodSchema = z .string() .trim() - .optional() + .nullish() .transform((val, ctx) => { if (val === undefined) return undefined; + if (!val || val === "permanent") return null; const parsedMs = ms(val); if (typeof parsedMs !== "number" || parsedMs <= 0) { diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index 0ed8b0582..997849800 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -41,7 +41,7 @@ export type TCreateAccessApprovalPolicy = { enforcementLevel: EnforcementLevel; allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; - maxTimePeriod?: string; + maxTimePeriod?: string | null; } & Omit; export type TUpdateAccessApprovalPolicy = { @@ -61,7 +61,7 @@ export type TUpdateAccessApprovalPolicy = { allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; environments?: string[]; - maxTimePeriod?: string; + maxTimePeriod?: string | null; } & Omit; export type TDeleteAccessApprovalPolicy = { diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 85c565caa..fc14352f8 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -18,7 +18,7 @@ export type TAccessApprovalPolicy = { approvers?: Approver[]; bypassers?: Bypasser[]; allowedSelfApprovals: boolean; - maxTimePeriod?: string; + maxTimePeriod?: string | null; }; export enum ApproverType { @@ -175,7 +175,7 @@ export type TCreateAccessPolicyDTO = { enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; - maxTimePeriod?: string; + maxTimePeriod?: string | null; }; export type TUpdateAccessPolicyDTO = { @@ -191,7 +191,7 @@ export type TUpdateAccessPolicyDTO = { // for invalidating list projectSlug: string; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; - maxTimePeriod?: string; + maxTimePeriod?: string | null; }; export type TDeleteSecretPolicyDTO = { From 6f05a6d82c51ff23f2aac4e898c6b63bc422c335 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 11 Aug 2025 17:11:33 -0700 Subject: [PATCH 5/8] feat(api): Return path for folder create, update, delete --- backend/src/db/schemas/secret-folders.ts | 3 ++- backend/src/server/routes/v1/secret-folder-router.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/db/schemas/secret-folders.ts b/backend/src/db/schemas/secret-folders.ts index 09e2fe8c1..799f2f24f 100644 --- a/backend/src/db/schemas/secret-folders.ts +++ b/backend/src/db/schemas/secret-folders.ts @@ -17,7 +17,8 @@ export const SecretFoldersSchema = z.object({ parentId: z.string().uuid().nullable().optional(), isReserved: z.boolean().default(false).nullable().optional(), description: z.string().nullable().optional(), - lastSecretModified: z.date().nullable().optional() + lastSecretModified: z.date().nullable().optional(), + path: z.string() }); export type TSecretFolders = z.infer; diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index b307347b8..cfc46ed99 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -45,7 +45,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .transform(removeTrailingSlash) .describe(FOLDERS.CREATE.path) .optional(), - // backward compatiability with cli + // backward compatibility with cli directory: z .string() .trim() @@ -130,7 +130,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .transform(removeTrailingSlash) .describe(FOLDERS.UPDATE.path) .optional(), - // backward compatiability with cli + // backward compatibility with cli directory: z .string() .trim() @@ -359,7 +359,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .transform(removeTrailingSlash) .describe(FOLDERS.LIST.path) .optional(), - // backward compatiability with cli + // backward compatibility with cli directory: z .string() .trim() From 272336092d48ce4a06730efb16f25b124c42d36f Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 11 Aug 2025 17:56:42 -0700 Subject: [PATCH 6/8] Fixed path return --- backend/src/db/schemas/secret-folders.ts | 3 +- .../server/routes/v1/secret-folder-router.ts | 8 +++-- .../secret-folder/secret-folder-service.ts | 31 +++++++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/backend/src/db/schemas/secret-folders.ts b/backend/src/db/schemas/secret-folders.ts index 799f2f24f..09e2fe8c1 100644 --- a/backend/src/db/schemas/secret-folders.ts +++ b/backend/src/db/schemas/secret-folders.ts @@ -17,8 +17,7 @@ export const SecretFoldersSchema = z.object({ parentId: z.string().uuid().nullable().optional(), isReserved: z.boolean().default(false).nullable().optional(), description: z.string().nullable().optional(), - lastSecretModified: z.date().nullable().optional(), - path: z.string() + lastSecretModified: z.date().nullable().optional() }); export type TSecretFolders = z.infer; diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index cfc46ed99..871259147 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -58,7 +58,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - folder: SecretFoldersSchema + folder: SecretFoldersSchema.extend({ + path: z.string() + }) }) } }, @@ -143,7 +145,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - folder: SecretFoldersSchema + folder: SecretFoldersSchema.extend({ + path: z.string() + }) }) } }, diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index a60d29348..90cc25710 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -238,8 +238,16 @@ export const secretFolderServiceFactory = ({ return doc; }); + const [folderWithFullPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folder.id]); + + if (!folderWithFullPath) { + throw new NotFoundError({ + message: `Failed to retrieve path for folder with ID '${folder.id}'` + }); + } + await snapshotService.performSnapshot(folder.parentId as string); - return folder; + return { ...folder, path: folderWithFullPath.path }; }; const updateManyFolders = async ({ @@ -496,8 +504,27 @@ export const secretFolderServiceFactory = ({ return doc; }); + const foldersWithFullPaths = await folderDAL.findSecretPathByFolderIds(projectId, [newFolder.id, folder.id]); + + const newFolderWithFullPath = foldersWithFullPaths.find((f) => f?.id === newFolder.id); + if (!newFolderWithFullPath) { + throw new NotFoundError({ + message: `Failed to retrieve path for folder with ID '${newFolder.id}'` + }); + } + + const folderWithFullPath = foldersWithFullPaths.find((f) => f?.id === folder.id); + if (!folderWithFullPath) { + throw new NotFoundError({ + message: `Failed to retrieve path for folder with ID '${folder.id}'` + }); + } + await snapshotService.performSnapshot(newFolder.parentId as string); - return { folder: newFolder, old: folder }; + return { + folder: { ...newFolder, path: newFolderWithFullPath.path }, + old: { ...folder, path: folderWithFullPath.path } + }; }; const $checkFolderPolicy = async ({ From 249b2933da0f071a743c2df574c986e6e3649b60 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 11 Aug 2025 19:18:46 -0700 Subject: [PATCH 7/8] Add stopRepeatableJob for removed bullMQ events that may still be on the queue --- .../resource-cleanup/resource-cleanup-queue.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 224eef4bf..dcfa7ea0d 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -49,6 +49,19 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ } const init = async () => { + await queueService.stopRepeatableJob( + QueueName.AuditLogPrune, + QueueJobs.AuditLogPrune, + { pattern: "0 0 * * *", utc: true }, + QueueName.AuditLogPrune // just a job id + ); + await queueService.stopRepeatableJob( + QueueName.DailyResourceCleanUp, + QueueJobs.DailyResourceCleanUp, + { pattern: "0 0 * * *", utc: true }, + QueueName.DailyResourceCleanUp // just a job id + ); + await queueService.startPg( QueueJobs.DailyResourceCleanUp, async () => { From e4d90eb055db06822d8242e4f22d087a621ff922 Mon Sep 17 00:00:00 2001 From: x032205 Date: Tue, 12 Aug 2025 12:12:31 -0700 Subject: [PATCH 8/8] Fix empty file infinite load --- .../components/SecretDropzone/SecretDropzone.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index 126306e3d..d7cb87da7 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -241,7 +241,14 @@ export const SecretDropzone = ({ setIsLoading.on(); reader.onload = (event) => { - if (!event?.target?.result) return; + if (!event?.target?.result) { + createNotification({ + type: "error", + text: "Invalid file contents." + }); + setIsLoading.off(); + return; + } let env: TParsedEnv;